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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime"
|
|
4
|
+
|
|
5
|
+
module Keiyaku
|
|
6
|
+
# Raised while translating something the generator cannot translate
|
|
7
|
+
# faithfully. Caught per operation, where it becomes a refusal; raised while
|
|
8
|
+
# building a component, it poisons that component and every operation that
|
|
9
|
+
# reaches it.
|
|
10
|
+
class Impossible < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Every Ruby name the generator invents is decided here, so that a collision
|
|
13
|
+
# is something one table can see rather than something each site discovers
|
|
14
|
+
# separately — and so that a name Ruby will not accept is a refusal at
|
|
15
|
+
# generation time rather than a SyntaxError at somebody's first require.
|
|
16
|
+
module Names
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
20
|
+
|
|
21
|
+
# KEYWORDS is Keiyaku's rather than this table's, because the runtime
|
|
22
|
+
# needs the same list: a parameter may be named for one, and the generated
|
|
23
|
+
# method body then has to read it back out of its binding.
|
|
24
|
+
|
|
25
|
+
# Names the generated client needs for itself. `def class` is legal Ruby
|
|
26
|
+
# and overrides the method every lookup in the runtime goes through, which
|
|
27
|
+
# is a client that recurses until the stack ends rather than one that
|
|
28
|
+
# fails to load.
|
|
29
|
+
CLIENT_METHODS = (Keiyaku::Client.instance_methods(false) +
|
|
30
|
+
Keiyaku::Client.private_instance_methods(false) + %i[initialize]).map(&:to_s).freeze
|
|
31
|
+
|
|
32
|
+
# A model's own contract: `with` and `to_h` are how a value type is used,
|
|
33
|
+
# `deconstruct_keys` is how it pattern matches, `to_json_hash` is how it
|
|
34
|
+
# becomes a request body, and `class` is how anything finds out what it
|
|
35
|
+
# is. A property taking one of those names leaves the model unable to do
|
|
36
|
+
# its job — unlike, say, one called `hash`, which plenty of real documents
|
|
37
|
+
# have and which costs only the model's use as a Hash key.
|
|
38
|
+
#
|
|
39
|
+
# The properties a document allows but does not name are read through the
|
|
40
|
+
# same `[]` that reads a declared one, so a model open to them spends no
|
|
41
|
+
# name here that a closed one does not — a reader called `extra` would
|
|
42
|
+
# have cost this table a word that real documents use as a property.
|
|
43
|
+
#
|
|
44
|
+
# `members` is not among them. The runtime asks the class for its members
|
|
45
|
+
# and never the instance, so a property of that name shadows a method
|
|
46
|
+
# nothing calls — and GitHub's AppPermissions has one.
|
|
47
|
+
MODEL_METHODS = %w[class with to_h deconstruct deconstruct_keys to_json to_json_hash].freeze
|
|
48
|
+
|
|
49
|
+
# The constants the generated files actually spend, and no more. `Client`
|
|
50
|
+
# is the class itself; `String` and the rest are what every other model's
|
|
51
|
+
# fields are declared as, so a schema of that name would quietly change
|
|
52
|
+
# what they cast to. A schema called `Range` or `File` shadows nothing
|
|
53
|
+
# here — the generated code never mentions either — and refusing it would
|
|
54
|
+
# be this table inventing a problem.
|
|
55
|
+
CONSTANTS = %w[Client Keiyaku String Integer Float Time Date].freeze
|
|
56
|
+
|
|
57
|
+
# `problem-details` and `problem_details` both want ProblemDetails, which
|
|
58
|
+
# is a collision the caller has to hear about rather than a name to
|
|
59
|
+
# invent a suffix for.
|
|
60
|
+
def constant(name)
|
|
61
|
+
const = name.to_s.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
|
|
62
|
+
.map { |part| Keiyaku.camelize(part).sub(/\A./, &:upcase) }.join
|
|
63
|
+
raise Impossible, "#{name.inspect} cannot be a Ruby constant" unless const.match?(/\A[A-Z][a-zA-Z0-9]*\z/)
|
|
64
|
+
raise Impossible, "#{name.inspect} would be #{const}, which is spoken for" if CONSTANTS.include?(const)
|
|
65
|
+
|
|
66
|
+
const
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The module the generated files declare. It is the caller's word rather
|
|
70
|
+
# than the document's, so what this catches is a `--module` that would
|
|
71
|
+
# become three files Ruby cannot read — reported against the flag that
|
|
72
|
+
# asked for it rather than as the generator blaming itself in the load
|
|
73
|
+
# check. One constant and no more: `module Acme::Api` needs an Acme that
|
|
74
|
+
# nothing here defines, and is a NameError at the first require.
|
|
75
|
+
def namespace(name)
|
|
76
|
+
unless name.to_s.match?(/\A[A-Z][a-zA-Z0-9]*\z/)
|
|
77
|
+
raise Impossible, "#{name.inspect} cannot be the module the generated files declare; " \
|
|
78
|
+
"it has to be one Ruby constant, like Petstore"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
name.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Not called `method`, which would shadow the one every object has — the
|
|
85
|
+
# kind of collision the rest of this table exists to refuse.
|
|
86
|
+
def operation(name)
|
|
87
|
+
# The separators have to become underscores before snake sees them: it
|
|
88
|
+
# drops anything that is not a word character, which would run the
|
|
89
|
+
# segments of a path together.
|
|
90
|
+
ruby = Keiyaku.snake(name.to_s.gsub(/[^a-zA-Z0-9]+/, "_")).squeeze("_").delete_prefix("_").delete_suffix("_")
|
|
91
|
+
raise Impossible, "#{name.inspect} cannot be a Ruby method name" unless ruby.match?(IDENTIFIER)
|
|
92
|
+
raise Impossible, "#{name.inspect} is a Ruby keyword" if KEYWORDS.include?(ruby)
|
|
93
|
+
raise Impossible, "#{name.inspect} is a method the client needs" if CLIENT_METHODS.include?(ruby)
|
|
94
|
+
|
|
95
|
+
ruby
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A parameter becomes an argument of the generated method, which is a
|
|
99
|
+
# local: it may shadow a method without harm. A keyword it may be too,
|
|
100
|
+
# but only where the argument is one — `def find(until: nil)` is a method
|
|
101
|
+
# Ruby will define and `def find(until)` is a file it will not read. So a
|
|
102
|
+
# query or header parameter keeps the document's name and the runtime
|
|
103
|
+
# reads it out of the binding, and a path parameter, which is positional
|
|
104
|
+
# because a URL's segments are ordered, is refused.
|
|
105
|
+
def parameter(name, positional: false)
|
|
106
|
+
ruby = Keiyaku.snake(name)
|
|
107
|
+
raise Impossible, "parameter #{name.inspect} cannot be a Ruby argument name" unless ruby.match?(IDENTIFIER)
|
|
108
|
+
if positional && KEYWORDS.include?(ruby)
|
|
109
|
+
raise Impossible, "path parameter #{name.inspect} is a Ruby keyword, which a positional argument cannot be"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
ruby
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A field is a model's member, which is a method reached through a dot and
|
|
116
|
+
# a Hash key written as a label. Both take a keyword: `{ end: String }`
|
|
117
|
+
# and `range.end` are ordinary Ruby, and a date range is not an unusual
|
|
118
|
+
# shape for a document to have. Only the names below are actually spent.
|
|
119
|
+
#
|
|
120
|
+
# A name Ruby will not take as an identifier keeps the one the document
|
|
121
|
+
# gave it — GitHub counts its reactions in properties called `+1` and
|
|
122
|
+
# `-1`. A member may be called that: it casts, round-trips, copies
|
|
123
|
+
# with `with` and pattern matches as `in { "+1": n }`. The one thing it
|
|
124
|
+
# cannot do is be reached through a dot, so `model["+1"]` is how it is
|
|
125
|
+
# read, and the RBS types that rather than an attr_reader. Renaming it to
|
|
126
|
+
# something dot-shaped would mean inventing a name the document never
|
|
127
|
+
# used, which is the guess this generator exists not to make.
|
|
128
|
+
#
|
|
129
|
+
# The test is the first character rather than the result, because snake
|
|
130
|
+
# drops what it cannot use: `+1` survives it as `1`, which is refused, but
|
|
131
|
+
# `-1` survives as `_1`, which is a perfectly good identifier for a field
|
|
132
|
+
# that has nothing to do with negative one. Both belong to the same
|
|
133
|
+
# schema, and a model reading `rollup["+1"]` beside `rollup._1` would be
|
|
134
|
+
# the generator having translated one of them and mangled the other.
|
|
135
|
+
def field(name)
|
|
136
|
+
ruby = Keiyaku.snake(name)
|
|
137
|
+
return name.to_s if !name.match?(/\A[a-zA-Z_]/) || !ruby.match?(IDENTIFIER)
|
|
138
|
+
raise Impossible, "property #{name.inspect} is a method the model needs" if MODEL_METHODS.include?(ruby)
|
|
139
|
+
|
|
140
|
+
ruby
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Whether a field can be written as a bare label, in Ruby and in RBS both.
|
|
144
|
+
def bare?(field) = field.match?(IDENTIFIER)
|
|
145
|
+
end
|
|
146
|
+
end
|