keiyaku 0.1.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 +7 -0
- data/CHANGELOG.md +149 -0
- data/DESIGN.md +418 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/exe/keiyaku +56 -0
- data/lib/keiyaku/adapters/faraday.rb +43 -0
- data/lib/keiyaku/adapters/http.rb +32 -0
- data/lib/keiyaku/adapters/net_http.rb +52 -0
- data/lib/keiyaku/emitter/rbs.rb +201 -0
- data/lib/keiyaku/emitter/security.rb +125 -0
- data/lib/keiyaku/emitter.rb +968 -0
- data/lib/keiyaku/names.rb +146 -0
- data/lib/keiyaku/runtime.rb +932 -0
- data/lib/keiyaku/version.rb +5 -0
- data/lib/keiyaku.rb +7 -0
- data/sig/keiyaku.rbs +216 -0
- metadata +67 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../names"
|
|
4
|
+
|
|
5
|
+
module Keiyaku
|
|
6
|
+
class Emitter
|
|
7
|
+
# What the document says a caller has to send, in the runtime's own
|
|
8
|
+
# vocabulary. A scheme nobody can implement is kept out of the table rather
|
|
9
|
+
# than out of the document: it only costs the operations that actually
|
|
10
|
+
# require it, and an operation that documents mutualTLS *or* an API key is
|
|
11
|
+
# one this client can still call.
|
|
12
|
+
#
|
|
13
|
+
# The schemes arrive already resolved, because a $ref is the Emitter's
|
|
14
|
+
# business and this has no document to look one up in.
|
|
15
|
+
class Security
|
|
16
|
+
def initialize(schemes:, default:, notes:)
|
|
17
|
+
@notes = notes
|
|
18
|
+
@schemes = {} # the document's name for a scheme => how the runtime is told to send it
|
|
19
|
+
@unsupported = {} # the document's name => what it is, for the message that refuses it
|
|
20
|
+
|
|
21
|
+
schemes.each do |name, scheme|
|
|
22
|
+
if (declaration = declaration_for(scheme))
|
|
23
|
+
@schemes[name] = declaration
|
|
24
|
+
else
|
|
25
|
+
@unsupported[name] = [scheme["type"], scheme["scheme"]].compact.join(" ")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@default = requirement_for(default)
|
|
30
|
+
# What an operation that says nothing about security gets. An
|
|
31
|
+
# alternative naming a scheme nothing can send is not one of them, and
|
|
32
|
+
# the operations that are left with none are refused one by one.
|
|
33
|
+
@usable = (@default || []).select { satisfiable?(_1) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# What the operation sends, as the DSL spells it — or nil where that is
|
|
37
|
+
# the client's default, which is declared once at the top of the class
|
|
38
|
+
# and does not need repeating on the method.
|
|
39
|
+
def source_for(name, op)
|
|
40
|
+
alternatives = for_operation(name, op)
|
|
41
|
+
requirement_source(alternatives) unless alternatives == @usable
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The schemes the whole document has, and the requirement that holds
|
|
45
|
+
# wherever an operation does not state one of its own. Both belong to the
|
|
46
|
+
# client rather than to any of its methods.
|
|
47
|
+
def table
|
|
48
|
+
return "" if @schemes.empty?
|
|
49
|
+
|
|
50
|
+
declarations = @schemes.map { |name, declaration| "#{scheme_key(name)} #{declaration}" }
|
|
51
|
+
default = @usable.empty? ? "" : ", default: #{requirement_source(@usable)}"
|
|
52
|
+
" security({ #{declarations.join(", ")} }#{default})\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def satisfiable?(schemes) = schemes.all? { @schemes.key?(_1) }
|
|
58
|
+
|
|
59
|
+
def declaration_for(scheme)
|
|
60
|
+
case [scheme["type"], scheme["scheme"]&.downcase, scheme["in"]]
|
|
61
|
+
in ["http", "bearer", _] then ":bearer"
|
|
62
|
+
in ["http", "basic", _] then ":basic"
|
|
63
|
+
# An OAuth 2 access token is a bearer token, and so is the one an
|
|
64
|
+
# OpenID Connect flow ends with; where it goes on the request is RFC
|
|
65
|
+
# 6750 rather than a guess. Obtaining it is the caller's business —
|
|
66
|
+
# nothing here runs a flow, which is also why the scopes a requirement
|
|
67
|
+
# lists say nothing this client could act on.
|
|
68
|
+
in ["oauth2" | "openIdConnect", _, _] then ":bearer"
|
|
69
|
+
in ["apiKey", _, "header"] then "{ header: #{scheme["name"].inspect} }"
|
|
70
|
+
in ["apiKey", _, "query"] then "{ query: #{scheme["name"].inspect} }"
|
|
71
|
+
in ["apiKey", _, "cookie"] then "{ cookie: #{scheme["name"].inspect} }"
|
|
72
|
+
else nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A security requirement is a list of alternatives, any one of which is
|
|
77
|
+
# enough, each naming schemes that all have to be satisfied. `security: []`
|
|
78
|
+
# is the operation that takes no credentials, which is not the same as the
|
|
79
|
+
# key being absent — that one takes the document's.
|
|
80
|
+
def requirement_for(declared) = declared&.map(&:keys)
|
|
81
|
+
|
|
82
|
+
# What one operation actually requires, with the alternatives the client
|
|
83
|
+
# could not satisfy dropped. Only when none are left is the operation
|
|
84
|
+
# refused.
|
|
85
|
+
def for_operation(name, op)
|
|
86
|
+
alternatives = op.key?("security") ? requirement_for(op["security"]) : @default
|
|
87
|
+
return [] if alternatives.nil? || alternatives.empty?
|
|
88
|
+
|
|
89
|
+
usable, rejected = alternatives.partition { satisfiable?(_1) }
|
|
90
|
+
|
|
91
|
+
rejected.each do |schemes|
|
|
92
|
+
unknown = schemes.reject { @schemes.key?(_1) || @unsupported.key?(_1) }
|
|
93
|
+
raise Impossible, "requires #{unknown.first.inspect}, which the document does not declare" if unknown.any?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if usable.empty?
|
|
97
|
+
needed = rejected.flatten.uniq.map { "#{_1} (#{@unsupported[_1]})" }
|
|
98
|
+
raise Impossible, "requires #{needed.join(" or ")}, which the runtime has no way to send"
|
|
99
|
+
end
|
|
100
|
+
if rejected.any?
|
|
101
|
+
@notes << "#{name}: sends #{usable.first.join(" and ")}; " \
|
|
102
|
+
"the document also allows #{rejected.map { _1.join(" and ") }.join(" or ")}, which is not supported"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
usable
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The shortest spelling of a requirement: a bare scheme name where there is
|
|
109
|
+
# one alternative naming one scheme, `false` where there is no requirement
|
|
110
|
+
# at all, and the alternatives written out where there is a choice.
|
|
111
|
+
def requirement_source(alternatives)
|
|
112
|
+
return "false" if alternatives.empty?
|
|
113
|
+
return alternatives.first.first.to_sym.inspect if alternatives.size == 1 && alternatives.first.size == 1
|
|
114
|
+
|
|
115
|
+
"[#{alternatives.map { |schemes| "[#{schemes.map { _1.to_sym.inspect }.join(", ")}]" }.join(", ")}]"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The document's own name for the scheme, which is what credentials are
|
|
119
|
+
# given by, so it is left exactly as the document spells it.
|
|
120
|
+
def scheme_key(name)
|
|
121
|
+
name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) ? "#{name}:" : "#{name.inspect}:"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|