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.
@@ -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