jekyll-openapi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +37 -15
- data/lib/jekyll_openapi/anchors.rb +45 -0
- data/lib/jekyll_openapi/content_type.rb +26 -0
- data/lib/jekyll_openapi/dereferencer.rb +23 -37
- data/lib/jekyll_openapi/document.rb +52 -2
- data/lib/jekyll_openapi/examples.rb +75 -0
- data/lib/jekyll_openapi/filters.rb +81 -4
- data/lib/jekyll_openapi/headers.rb +27 -0
- data/lib/jekyll_openapi/parameters.rb +10 -2
- data/lib/jekyll_openapi/reference.rb +143 -0
- data/lib/jekyll_openapi/schema.rb +189 -0
- data/lib/jekyll_openapi/schema_renderer/html.rb +186 -0
- data/lib/jekyll_openapi/schema_renderer/typescript.rb +59 -49
- data/lib/jekyll_openapi/schema_renderer/xml.rb +48 -29
- data/lib/jekyll_openapi/version.rb +1 -1
- data/lib/jekyll_openapi.rb +7 -0
- metadata +8 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "json_pointer"
|
|
3
|
+
|
|
4
|
+
module JekyllOpenAPI
|
|
5
|
+
##
|
|
6
|
+
# Lazy, name-preserving stand-in for a JSON `$ref`.
|
|
7
|
+
#
|
|
8
|
+
# The Dereferencer replaces each `{"$ref" => ...}` node with a Reference
|
|
9
|
+
# rather than inlining its target. This keeps the reference *graph* — and the
|
|
10
|
+
# component *name* — intact, which buys three things the old inlining threw
|
|
11
|
+
# away:
|
|
12
|
+
#
|
|
13
|
+
# * renderers/templates can link to a named schema instead of always
|
|
14
|
+
# expanding it (see Schema#name);
|
|
15
|
+
# * a schema reused N times resolves to one shared object, not N deep copies;
|
|
16
|
+
# * recursion terminates, because targets are only followed on demand.
|
|
17
|
+
#
|
|
18
|
+
# For read access a Reference transparently delegates to its resolved target
|
|
19
|
+
# (`ref["type"]`, `ref.dig("schema", "pattern")`, iteration), so plain
|
|
20
|
+
# hash-walking templates keep working unchanged. `#reference?`, `#name` and
|
|
21
|
+
# `#ref` expose the pointer for code that wants to link rather than follow.
|
|
22
|
+
class Reference
|
|
23
|
+
# Shared per-dereference state: the linked root (for pointer resolution) and
|
|
24
|
+
# the logger. Populated by the Dereferencer once the whole tree is linked.
|
|
25
|
+
Context = Struct.new(:root, :logger)
|
|
26
|
+
|
|
27
|
+
attr_reader :ref, :overrides
|
|
28
|
+
|
|
29
|
+
# @param ref [String] the raw pointer, e.g. "#/components/schemas/Pet"
|
|
30
|
+
# @param context [Context] shared resolution state
|
|
31
|
+
# @param overrides [Hash] sibling summary/description overriding the target
|
|
32
|
+
def initialize(ref, context, overrides = {})
|
|
33
|
+
@ref = ref
|
|
34
|
+
@context = context
|
|
35
|
+
@overrides = overrides
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reference?
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when the pointer is local to the current document (starts with "#").
|
|
43
|
+
def local?
|
|
44
|
+
@ref.is_a?(String) && @ref.start_with?("#")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True when the pointer targets another file or URL (unsupported for now).
|
|
48
|
+
def external?
|
|
49
|
+
!local?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Component name, e.g. "Pet" for "#/components/schemas/Pet"; nil when external.
|
|
53
|
+
def name
|
|
54
|
+
return nil unless local?
|
|
55
|
+
@name ||= JSONPointer.unescape(@ref.delete_prefix("#").split("/").last)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The target node, lazily resolved and memoized, with `summary`/`description`
|
|
59
|
+
# overrides applied. Nested `$ref`s inside it are themselves References.
|
|
60
|
+
# Returns nil (warning once) for external or unresolvable pointers.
|
|
61
|
+
def resolve
|
|
62
|
+
return @resolved if @resolved_computed
|
|
63
|
+
@resolved_computed = true
|
|
64
|
+
@resolved = compute_resolution
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- hash-like read delegation to the resolved target --------------------
|
|
68
|
+
|
|
69
|
+
def [](key)
|
|
70
|
+
return @ref if key == "$ref"
|
|
71
|
+
target = resolve
|
|
72
|
+
mapping?(target) ? target[key] : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def key?(key)
|
|
76
|
+
return true if key == "$ref"
|
|
77
|
+
target = resolve
|
|
78
|
+
mapping?(target) && target.key?(key)
|
|
79
|
+
end
|
|
80
|
+
alias_method :include?, :key?
|
|
81
|
+
|
|
82
|
+
def fetch(key, *default)
|
|
83
|
+
return self[key] if key?(key)
|
|
84
|
+
return yield(key) if block_given?
|
|
85
|
+
return default.first unless default.empty?
|
|
86
|
+
raise KeyError, "key not found: #{key.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dig(key, *rest)
|
|
90
|
+
value = self[key]
|
|
91
|
+
return value if rest.empty? || value.nil?
|
|
92
|
+
value.respond_to?(:dig) ? value.dig(*rest) : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def each(&block)
|
|
96
|
+
target = resolve
|
|
97
|
+
target.each(&block) if target.respond_to?(:each)
|
|
98
|
+
end
|
|
99
|
+
alias_method :each_pair, :each
|
|
100
|
+
|
|
101
|
+
# Liquid indexes us like the hash we stand for (it calls #to_liquid on every
|
|
102
|
+
# value and does not patch arbitrary objects), so hand it back ourselves.
|
|
103
|
+
def to_liquid
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A Reference merges like the hash it stands for (used by Parameters.merge).
|
|
108
|
+
def to_hash
|
|
109
|
+
target = resolve
|
|
110
|
+
target.is_a?(Hash) ? target : { "$ref" => @ref }
|
|
111
|
+
end
|
|
112
|
+
alias_method :to_h, :to_hash
|
|
113
|
+
|
|
114
|
+
# Serialize back to a $ref node. Finite by construction (it never expands the
|
|
115
|
+
# target), so `oapi_dereference | jsonify` stays bounded even for recursive
|
|
116
|
+
# schemas, and round-trips to a valid OpenAPI reference.
|
|
117
|
+
def to_json(*args)
|
|
118
|
+
{ "$ref" => @ref }.to_json(*args)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def mapping?(node)
|
|
124
|
+
node.is_a?(Hash) || node.is_a?(Reference)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def compute_resolution
|
|
128
|
+
unless local?
|
|
129
|
+
@context.logger.warn("external or invalid $ref left unresolved: #{@ref.inspect}")
|
|
130
|
+
return nil
|
|
131
|
+
end
|
|
132
|
+
target = JSONPointer.resolve(@context.root, @ref.delete_prefix("#"))
|
|
133
|
+
if target.nil?
|
|
134
|
+
@context.logger.warn("unresolvable $ref: #{@ref}")
|
|
135
|
+
return nil
|
|
136
|
+
end
|
|
137
|
+
# Apply OpenAPI 3.1 sibling overrides without disturbing the shared target
|
|
138
|
+
# (so diamond reuse keeps object identity when there are no overrides).
|
|
139
|
+
return target unless target.is_a?(Hash) && !@overrides.empty?
|
|
140
|
+
target.merge(@overrides)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require_relative "reference"
|
|
2
|
+
require_relative "json_pointer"
|
|
3
|
+
|
|
4
|
+
module JekyllOpenAPI
|
|
5
|
+
##
|
|
6
|
+
# Normalized, ref-aware view over a JSON-Schema node, shared by every schema
|
|
7
|
+
# renderer.
|
|
8
|
+
#
|
|
9
|
+
# It centralises the two things that used to be copy-pasted (and drift) across
|
|
10
|
+
# the TypeScript and XML renderers: the type *inference* that fills in an
|
|
11
|
+
# omitted `type`, and the `$ref` *following* with cycle detection. A new
|
|
12
|
+
# renderer (JSON example, HTML, …) implements formatting only — it never walks
|
|
13
|
+
# raw hashes or re-infers types.
|
|
14
|
+
#
|
|
15
|
+
# Schemas are immutable and carry the set of `$ref` pointers already followed
|
|
16
|
+
# on the current path (`seen`), so following a self-referential schema stops
|
|
17
|
+
# instead of looping, while genuine reuse (the same schema in two branches)
|
|
18
|
+
# is never mistaken for a cycle.
|
|
19
|
+
class Schema
|
|
20
|
+
# Sentinel returned by #follow when a `$ref` points back onto the path.
|
|
21
|
+
CYCLE = :cycle
|
|
22
|
+
|
|
23
|
+
def self.wrap(node, seen = [])
|
|
24
|
+
node.is_a?(Schema) ? node : new(node, seen)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :seen
|
|
28
|
+
|
|
29
|
+
def initialize(node, seen = [])
|
|
30
|
+
@node = node
|
|
31
|
+
@seen = seen
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def raw
|
|
35
|
+
@node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether this wraps something readable as a schema object.
|
|
39
|
+
def mapping?
|
|
40
|
+
@node.is_a?(Hash) || @node.is_a?(Reference)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A $ref node — either a linked {Reference} (produced by the Dereferencer)
|
|
44
|
+
# or a raw, un-dereferenced `{"$ref" => "..."}` hash. Link-mode rendering
|
|
45
|
+
# only needs the pointer string, so it works on either form (see #ref/#name);
|
|
46
|
+
# *following* a ref to inline its target still requires a linked Reference
|
|
47
|
+
# (a raw ref carries no root to resolve against — see #follow).
|
|
48
|
+
def reference?
|
|
49
|
+
@node.is_a?(Reference) || raw_reference?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A plain `{"$ref" => "..."}` hash that never went through the Dereferencer.
|
|
53
|
+
def raw_reference?
|
|
54
|
+
@node.is_a?(Hash) && @node["$ref"].is_a?(String)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The raw pointer string, e.g. "#/components/schemas/Pet"; nil when not a ref.
|
|
58
|
+
def ref
|
|
59
|
+
return @node.ref if @node.is_a?(Reference)
|
|
60
|
+
return @node["$ref"] if raw_reference?
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Component name of a local ref, e.g. "Pet" for "#/components/schemas/Pet";
|
|
65
|
+
# nil for external refs or non-refs. Derived from the pointer alone, so it
|
|
66
|
+
# needs no dereferencing.
|
|
67
|
+
def name
|
|
68
|
+
pointer = ref
|
|
69
|
+
return nil unless pointer.is_a?(String) && pointer.start_with?("#")
|
|
70
|
+
JSONPointer.unescape(pointer.delete_prefix("#").split("/").last)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A local $ref to a named component — the kind an HTML renderer links to
|
|
74
|
+
# rather than inlines. External refs (no local name) return false.
|
|
75
|
+
def named_reference?
|
|
76
|
+
reference? && !name.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The use-site description sitting beside a $ref (a 3.1 sibling override),
|
|
80
|
+
# if any. Lets a renderer annotate a link without expanding its target.
|
|
81
|
+
def override_description
|
|
82
|
+
return @node.overrides["description"] if @node.is_a?(Reference)
|
|
83
|
+
return @node["description"] if raw_reference?
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Follow a `$ref` one hop.
|
|
88
|
+
# @return [Schema] the target when resolvable,
|
|
89
|
+
# CYCLE when the ref is already on the current path,
|
|
90
|
+
# nil when external, unresolvable, or a raw (never-dereferenced) ref that
|
|
91
|
+
# carries no root to resolve against (a warning is emitted by Reference).
|
|
92
|
+
def follow
|
|
93
|
+
return self unless reference?
|
|
94
|
+
pointer = ref
|
|
95
|
+
return CYCLE if @seen.include?(pointer)
|
|
96
|
+
target = @node.is_a?(Reference) ? @node.resolve : nil
|
|
97
|
+
target.nil? ? nil : Schema.new(target, @seen + [pointer])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Raw read, delegating through a Reference transparently.
|
|
101
|
+
def [](key)
|
|
102
|
+
mapping? ? @node[key] : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dig(*keys)
|
|
106
|
+
mapping? ? @node.dig(*keys) : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- composition ---------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# @return [Array(String, Array<Schema>), nil] e.g. ["oneOf", [Schema, ...]]
|
|
112
|
+
def composition
|
|
113
|
+
%w[oneOf anyOf allOf].each do |keyword|
|
|
114
|
+
members = self[keyword]
|
|
115
|
+
return [keyword, members.map { |m| child(m) }] if members.is_a?(Array)
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- type ----------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def declared_type
|
|
123
|
+
self["type"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def type_union?
|
|
127
|
+
declared_type.is_a?(Array)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Single inferred type, mirroring what most tooling assumes when `type` is
|
|
131
|
+
# omitted. Returns nil for a type union (see #type_union?) or the unknown.
|
|
132
|
+
def type
|
|
133
|
+
declared = declared_type
|
|
134
|
+
return declared if declared.is_a?(String)
|
|
135
|
+
return nil if declared.is_a?(Array)
|
|
136
|
+
return "object" if self["properties"] || self["additionalProperties"]
|
|
137
|
+
return "array" if self["items"]
|
|
138
|
+
return "string" if self["enum"]
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A copy of this schema with `type` pinned to one member of a type union.
|
|
143
|
+
def with_type(single)
|
|
144
|
+
node = @node.is_a?(Hash) ? @node.merge("type" => single) : @node
|
|
145
|
+
Schema.new(node, @seen)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# --- object --------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def properties
|
|
151
|
+
(self["properties"] || {}).map { |key, node| [key, child(node)] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def required?(key)
|
|
155
|
+
Array(self["required"]).include?(key)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Schema, nil] the additionalProperties schema, only when it is a
|
|
159
|
+
# schema object (the boolean `true`/`false` forms carry no shape to render)
|
|
160
|
+
def additional_properties
|
|
161
|
+
value = self["additionalProperties"]
|
|
162
|
+
value.is_a?(Hash) || value.is_a?(Reference) ? child(value) : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- array ---------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def items
|
|
168
|
+
child(self["items"])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# --- leaf metadata -------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def enum = self["enum"]
|
|
174
|
+
def format = self["format"]
|
|
175
|
+
def pattern = self["pattern"]
|
|
176
|
+
def summary = self["summary"]
|
|
177
|
+
def description = self["description"]
|
|
178
|
+
def example = self["example"]
|
|
179
|
+
def examples = self["examples"]
|
|
180
|
+
def xml = self["xml"] || {}
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Descending into a member keeps the current cycle path (no ref followed).
|
|
185
|
+
def child(node)
|
|
186
|
+
Schema.wrap(node, @seen)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require_relative "../schema"
|
|
3
|
+
|
|
4
|
+
module JekyllOpenAPI
|
|
5
|
+
module SchemaRenderer
|
|
6
|
+
##
|
|
7
|
+
# Renders a JSON-Schema as semantic, linkable HTML.
|
|
8
|
+
#
|
|
9
|
+
# Unlike the TypeScript/XML renderers — which always follow `$ref`s and
|
|
10
|
+
# inline everything — this renderer works in *link mode*: a reference to a
|
|
11
|
+
# named component schema becomes an anchor to that schema's definition
|
|
12
|
+
# (`#schema-<name>`) instead of being expanded. Anonymous inline schemas are
|
|
13
|
+
# rendered structurally. That keeps operation bodies compact, cross-links
|
|
14
|
+
# reused types, and makes recursion terminate for free (a link is never
|
|
15
|
+
# followed).
|
|
16
|
+
#
|
|
17
|
+
# Markup is semantic and class-annotated (`<dl>`/`<ul>`/`<div>`, never
|
|
18
|
+
# tables); all styling is left to the consumer. Every class is `oapi-`
|
|
19
|
+
# prefixed. Emitted text is HTML-escaped.
|
|
20
|
+
#
|
|
21
|
+
# Descriptions are plain-escaped by default. Pass a `markdown:` callable
|
|
22
|
+
# (`text -> html`) to render them as markdown instead — the core library has
|
|
23
|
+
# no markdown engine of its own, so the caller supplies one (the Jekyll
|
|
24
|
+
# filter wires in the site's converter; see Filters#oapi_html_schema).
|
|
25
|
+
class HTML
|
|
26
|
+
# Fragment prefix for a named schema, e.g. "schema-Pet". A Models section
|
|
27
|
+
# anchors each definition with the matching id (see oapi_schema_anchor).
|
|
28
|
+
ANCHOR_PREFIX = "schema-"
|
|
29
|
+
|
|
30
|
+
def self.anchor(name)
|
|
31
|
+
"#{ANCHOR_PREFIX}#{name}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param markdown [#call, nil] a `text -> html` converter for descriptions;
|
|
35
|
+
# when nil, descriptions are HTML-escaped plain text
|
|
36
|
+
def initialize(logger: JekyllOpenAPI.logger, markdown: nil)
|
|
37
|
+
@logger = logger
|
|
38
|
+
@markdown = markdown
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.render(schema, logger: JekyllOpenAPI.logger, markdown: nil)
|
|
42
|
+
new(logger: logger, markdown: markdown).render(schema)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param input [Hash, Reference, Schema] a JSON-Schema
|
|
46
|
+
# @return [String] HTML
|
|
47
|
+
def render(input)
|
|
48
|
+
schema = Schema.wrap(input)
|
|
49
|
+
|
|
50
|
+
if schema.named_reference?
|
|
51
|
+
# Link, don't follow. Any use-site (sibling) description still shows.
|
|
52
|
+
return annotate(link(schema), schema.override_description)
|
|
53
|
+
end
|
|
54
|
+
if schema.reference?
|
|
55
|
+
# External/unnamed ref: surface the pointer, don't fake a type.
|
|
56
|
+
return ref_code(schema)
|
|
57
|
+
end
|
|
58
|
+
unless schema.mapping?
|
|
59
|
+
@logger.warn("not a valid schema object: #{schema.raw.inspect}")
|
|
60
|
+
return ""
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
html = base(schema)
|
|
64
|
+
return "" if html.nil?
|
|
65
|
+
annotate(html, schema.description)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def base(schema)
|
|
71
|
+
return composition(schema) if schema.composition
|
|
72
|
+
return type_union(schema) if schema.type_union?
|
|
73
|
+
|
|
74
|
+
case schema.type
|
|
75
|
+
when "object" then object(schema)
|
|
76
|
+
when "array" then array(schema)
|
|
77
|
+
when "string" then string(schema)
|
|
78
|
+
when "null" then span("oapi-type", "null")
|
|
79
|
+
when nil
|
|
80
|
+
@logger.warn("not a valid schema object: #{schema.raw.inspect}")
|
|
81
|
+
nil
|
|
82
|
+
else
|
|
83
|
+
primitive(schema)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def object(schema)
|
|
88
|
+
rows = schema.properties.map do |key, property|
|
|
89
|
+
required = schema.required?(key)
|
|
90
|
+
name = "#{span("oapi-prop-name", key)}#{required ? required_marker : ""}"
|
|
91
|
+
<<~ROW.chomp
|
|
92
|
+
<div class="oapi-prop#{required ? " oapi-prop-required" : ""}">
|
|
93
|
+
<dt>#{name}</dt>
|
|
94
|
+
<dd>#{render(property)}</dd>
|
|
95
|
+
</div>
|
|
96
|
+
ROW
|
|
97
|
+
end
|
|
98
|
+
rows << additional_row(schema)
|
|
99
|
+
"<dl class=\"oapi-object\">#{rows.compact.join}</dl>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# additionalProperties: a schema (typed map), `true` (open) or absent.
|
|
103
|
+
def additional_row(schema)
|
|
104
|
+
value =
|
|
105
|
+
if (additional = schema.additional_properties)
|
|
106
|
+
render(additional)
|
|
107
|
+
elsif schema["additionalProperties"] == true
|
|
108
|
+
span("oapi-type", "any")
|
|
109
|
+
end
|
|
110
|
+
return nil unless value
|
|
111
|
+
<<~ROW.chomp
|
|
112
|
+
<div class="oapi-prop oapi-prop-additional">
|
|
113
|
+
<dt>#{span("oapi-additional-key", "additional properties")}</dt>
|
|
114
|
+
<dd>#{value}</dd>
|
|
115
|
+
</div>
|
|
116
|
+
ROW
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def array(schema)
|
|
120
|
+
"<div class=\"oapi-array\">#{span("oapi-array-label", "array of")} #{render(schema.items)}</div>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def string(schema)
|
|
124
|
+
if schema.enum
|
|
125
|
+
values = schema.enum.map { |value| "<li>#{code("oapi-enum-value", value)}</li>" }.join
|
|
126
|
+
"<ul class=\"oapi-enum\">#{values}</ul>"
|
|
127
|
+
else
|
|
128
|
+
primitive(schema, "string")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def primitive(schema, type = schema.type)
|
|
133
|
+
html = span("oapi-type", type)
|
|
134
|
+
html += " #{span("oapi-format", "(#{schema.format})")}" if schema.format
|
|
135
|
+
html += " #{code("oapi-pattern", "/#{schema.pattern}/")}" if schema.pattern
|
|
136
|
+
html
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def composition(schema)
|
|
140
|
+
keyword, members = schema.composition
|
|
141
|
+
items = members.map { |member| "<li>#{render(member)}</li>" }.join
|
|
142
|
+
"<ul class=\"oapi-union\" data-kind=\"#{keyword}\">#{items}</ul>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# OpenAPI 3.1 type array, e.g. ["string", "null"]
|
|
146
|
+
def type_union(schema)
|
|
147
|
+
types = schema.declared_type
|
|
148
|
+
parts = (types - ["null"]).map { |type| base(schema.with_type(type)) }
|
|
149
|
+
parts << span("oapi-nullable", "null") if types.include?("null")
|
|
150
|
+
parts.join(" #{span("oapi-or", "|")} ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def link(schema)
|
|
154
|
+
name = schema.name
|
|
155
|
+
"<a class=\"oapi-ref\" href=\"##{ANCHOR_PREFIX}#{CGI.escapeHTML(name)}\">#{escape(name)}</a>"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ref_code(schema)
|
|
159
|
+
code("oapi-ref-external", schema.ref)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def annotate(html, description)
|
|
163
|
+
return html if description.nil? || description.empty?
|
|
164
|
+
# A markdown converter returns trusted HTML; without one, escape the text.
|
|
165
|
+
body = @markdown ? @markdown.call(description) : escape(description)
|
|
166
|
+
"#{html}<div class=\"oapi-desc\">#{body}</div>"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def required_marker
|
|
170
|
+
"<span class=\"oapi-required\">required</span>"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def span(cls, text)
|
|
174
|
+
"<span class=\"#{cls}\">#{escape(text)}</span>"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def code(cls, text)
|
|
178
|
+
"<code class=\"#{cls}\">#{escape(text)}</code>"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def escape(text)
|
|
182
|
+
CGI.escapeHTML(text.to_s)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require_relative "../schema"
|
|
2
|
+
|
|
1
3
|
module JekyllOpenAPI
|
|
2
4
|
module SchemaRenderer
|
|
3
5
|
##
|
|
4
6
|
# Renders a JSON-Schema as a simplified TypeScript-like textual representation.
|
|
7
|
+
# Traversal, type inference and `$ref` following live in {Schema}; this class
|
|
8
|
+
# only decides how each shape prints.
|
|
5
9
|
# @see https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00
|
|
6
10
|
class TypeScript
|
|
7
11
|
def initialize(logger: JekyllOpenAPI.logger)
|
|
@@ -12,94 +16,100 @@ module JekyllOpenAPI
|
|
|
12
16
|
new(logger: logger).render(schema)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
# @param input [Hash] a JSON-Schema
|
|
19
|
+
# @param input [Hash, Reference, Schema] a JSON-Schema
|
|
16
20
|
# @param prefix [String] current indentation
|
|
17
21
|
# @return [String]
|
|
18
22
|
def render(input, prefix = "")
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
schema = Schema.wrap(input)
|
|
24
|
+
|
|
25
|
+
if schema.reference?
|
|
26
|
+
followed = schema.follow
|
|
27
|
+
# A cycle prints the schema's name (e.g. "Node") instead of looping.
|
|
28
|
+
return schema.name.to_s if followed == Schema::CYCLE
|
|
29
|
+
return "" if followed.nil?
|
|
30
|
+
return render(followed, prefix)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unless schema.mapping?
|
|
34
|
+
@logger.warn("not a valid schema object: #{schema.raw.inspect}")
|
|
21
35
|
return ""
|
|
22
36
|
end
|
|
23
|
-
|
|
37
|
+
|
|
38
|
+
str = base(schema, prefix)
|
|
24
39
|
return "" if str.nil?
|
|
25
|
-
annotate(str,
|
|
40
|
+
annotate(str, schema)
|
|
26
41
|
end
|
|
27
42
|
|
|
28
43
|
private
|
|
29
44
|
|
|
30
|
-
def base(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return type_union(
|
|
37
|
-
# infer the type when omitted, as most tooling does
|
|
38
|
-
type ||= "object" if input["properties"] || input["additionalProperties"]
|
|
39
|
-
type ||= "array" if input["items"]
|
|
40
|
-
type ||= "string" if input["enum"]
|
|
45
|
+
def base(schema, prefix)
|
|
46
|
+
if (composition = schema.composition)
|
|
47
|
+
keyword, members = composition
|
|
48
|
+
separator = keyword == "allOf" ? " & " : " | "
|
|
49
|
+
return members.map { |member| render(member, prefix) }.join(separator)
|
|
50
|
+
end
|
|
51
|
+
return type_union(schema, prefix) if schema.type_union?
|
|
41
52
|
|
|
42
|
-
case type
|
|
43
|
-
when "object" then object(
|
|
44
|
-
when "array" then "[ #{render(
|
|
45
|
-
when "string" then string(
|
|
53
|
+
case schema.type
|
|
54
|
+
when "object" then object(schema, prefix)
|
|
55
|
+
when "array" then "[ #{render(schema.items, prefix)} ]"
|
|
56
|
+
when "string" then string(schema)
|
|
46
57
|
when "null" then "null"
|
|
47
58
|
when nil
|
|
48
|
-
@logger.warn("not a valid schema object: #{
|
|
59
|
+
@logger.warn("not a valid schema object: #{schema.raw.inspect}")
|
|
49
60
|
nil
|
|
50
61
|
else
|
|
51
|
-
primitive(
|
|
62
|
+
primitive(schema)
|
|
52
63
|
end
|
|
53
64
|
end
|
|
54
65
|
|
|
55
|
-
def union(schemas, prefix, separator)
|
|
56
|
-
schemas.map { |schema| render(schema, prefix) }.join(separator)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
66
|
# OpenAPI 3.1 / JSON-Schema type arrays, e.g. type: [string, "null"]
|
|
60
|
-
def type_union(
|
|
61
|
-
types =
|
|
62
|
-
rendered = (types - ["null"]).map { |t| base(
|
|
67
|
+
def type_union(schema, prefix)
|
|
68
|
+
types = schema.declared_type
|
|
69
|
+
rendered = (types - ["null"]).map { |t| base(schema.with_type(t), prefix) }
|
|
63
70
|
rendered.push("null") if types.include?("null")
|
|
64
71
|
rendered.join(" | ")
|
|
65
72
|
end
|
|
66
73
|
|
|
67
|
-
def object(
|
|
74
|
+
def object(schema, prefix)
|
|
68
75
|
str = "{"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
str += "\n#{prefix} #{key}#{
|
|
76
|
+
schema.properties.each do |key, property|
|
|
77
|
+
optional = schema.required?(key) ? "" : "?"
|
|
78
|
+
str += "\n#{prefix} #{key}#{optional}: #{render(property, prefix + " ")}"
|
|
72
79
|
end
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
if (additional = schema.additional_properties)
|
|
81
|
+
str += "\n#{prefix} [#{additional.type}]?: #{render(additional, prefix + " ")}"
|
|
82
|
+
elsif schema["additionalProperties"] == true
|
|
83
|
+
# open map with an unconstrained value type; `false` is the closed
|
|
84
|
+
# default and needs no annotation
|
|
85
|
+
str += "\n#{prefix} [string]?: any"
|
|
76
86
|
end
|
|
77
87
|
"#{str}\n#{prefix}}"
|
|
78
88
|
end
|
|
79
89
|
|
|
80
|
-
def string(
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
elsif
|
|
90
|
+
def string(schema)
|
|
91
|
+
if schema.enum
|
|
92
|
+
schema.enum.map { |value| "\"#{value}\"" }.join("|")
|
|
93
|
+
elsif schema.format == "binary"
|
|
84
94
|
"Buffer"
|
|
85
95
|
else
|
|
86
96
|
"\"string\""
|
|
87
97
|
end
|
|
88
98
|
end
|
|
89
99
|
|
|
90
|
-
def primitive(
|
|
91
|
-
str = type.to_s
|
|
92
|
-
str += " (#{
|
|
100
|
+
def primitive(schema)
|
|
101
|
+
str = schema.type.to_s
|
|
102
|
+
str += " (#{schema.format})" if schema.format
|
|
93
103
|
str
|
|
94
104
|
end
|
|
95
105
|
|
|
96
|
-
def annotate(str,
|
|
97
|
-
str = "#{str} // /#{
|
|
98
|
-
if
|
|
99
|
-
str = "#{str} //#{
|
|
100
|
-
elsif
|
|
106
|
+
def annotate(str, schema)
|
|
107
|
+
str = "#{str} // /#{schema.pattern}/" if schema.pattern
|
|
108
|
+
if schema.summary
|
|
109
|
+
str = "#{str} //#{schema.summary}"
|
|
110
|
+
elsif schema.description
|
|
101
111
|
padding = "".rjust(str.length, " ")
|
|
102
|
-
str = "#{str} //#{
|
|
112
|
+
str = "#{str} //#{schema.description.split("\n").join("\n#{padding} //")}"
|
|
103
113
|
end
|
|
104
114
|
str
|
|
105
115
|
end
|