Module: Spikard::Introspection

Defined in:
lib/spikard/introspection.rb

Overview

Handler introspection via Method#parameters.

Defined Under Namespace

Classes: Binding, RouteSpec

Constant Summary collapse

BODYLESS_METHODS =
Set.new(%w[GET HEAD OPTIONS TRACE CONNECT]).freeze
SPECIAL_PARAM_NAMES =
Set.new(%w[self]).freeze

Class Method Summary collapse

Class Method Details

.derive_json_schema(body_type) ⇒ Object

Derive JSON Schema from a Dry::Struct class.

Returns nil if body_type is not a Dry::Struct. Otherwise builds a JSON Schema object by iterating the struct's schema attributes.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/spikard/introspection.rb', line 45

def self.derive_json_schema(body_type)
  return nil unless body_type.is_a?(Class) && body_type < Dry::Struct

  properties = {}
  required = []

  body_type.schema.each do |key|
    attr_name = key.name.to_s
    attr_type = key.type
    attr_required = key.required?

    # Map Dry type to JSON Schema type
    json_type = json_schema_type_for(attr_type)

    properties[attr_name] = {
      "type" => json_type
    }

    required << attr_name if attr_required
  end

  {
    "type" => "object",
    "properties" => properties,
    "required" => required
  }
end

.introspect(handler, method, path, body_type) ⇒ Object

Analyse handler and produce a RouteSpec describing how to bind requests.

The handler receives the hydrated body DTO (if body_type provided) as its FIRST POSITIONAL parameter, and remaining keyword-arg bindings by name.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/spikard/introspection.rb', line 104

def self.introspect(handler, method, path, body_type)
  path_names = path_param_names(path)

  bindings = []
  body_param_name = nil

  # The first positional parameter (if any) becomes the body param when
  # body_type is provided. All other parameters become keyword-arg bindings.
  positional_idx = 0

  # Use handler.parameters (the block's own params), NOT
  # handler.method(:call).parameters, which for a Proc reflects Proc#call's
  # signature ([[:rest]]) rather than the block's declared params.
  handler.parameters.each do |param_type, param_name|
    next if SPECIAL_PARAM_NAMES.include?(param_name.to_s)
    next if param_name.nil?

    # Skip *args and **kwargs
    next if param_type == :rest

    param_name_str = param_name.to_s

    # First required/optional positional parameter becomes the body param
    # (only if body_type was explicitly provided)
    if body_type && (param_type == :req || param_type == :opt) && positional_idx == 0
      body_param_name = param_name_str
      positional_idx += 1
      next
    end

    # Keyword parameters become bindings (query, path, header, cookie)
    if param_type == :key || param_type == :keyreq
      # Determine source by name (path > query)
      source = if path_names.include?(param_name_str)
        "path"
      else
        "query"
      end

      bindings <<
        Binding.new(
          param_name_str,
          source,
          String,
          nil
        )
    end
  end

  RouteSpec.new(handler, bindings, body_param_name, body_type)
end

.json_schema_type_for(dry_type) ⇒ Object

Map a Dry::Types type to a JSON Schema type string.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/spikard/introspection.rb', line 74

def self.json_schema_type_for(dry_type)
  # Get the primitive class from the Dry type.
  primitive = dry_type.respond_to?(:primitive) ? dry_type.primitive : nil

  # Match on the class NAME, not `case primitive when Integer`: `===` on a
  # class (`Integer === Integer`) is false because the class object is not
  # an instance of itself, so a `when` clause would never fire.
  case primitive.to_s
  when "String", "Symbol"
    "string"
  when "Integer"
    "integer"
  when "Float", "Numeric", "BigDecimal"
    "number"
  when "TrueClass", "FalseClass"
    "boolean"
  when "Array"
    "array"
  when "Hash"
    "object"
  else
    # Default fallback
    "string"
  end
end

.path_param_names(path) ⇒ Object

Return the set of path-parameter names in path (handles id and id:int).



157
158
159
160
# File 'lib/spikard/introspection.rb', line 157

def self.path_param_names(path)
  # Match {name} and typed {name:converter} path segments
  path.scan(/\{(\w+)(?::[^{}]+)?\}/).map(&:first).to_set
end