Class: Synthra::OpenAPIImporter

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/openapi_importer.rb

Overview

OpenAPI/Swagger Schema Importer

Converts OpenAPI specifications to Synthra schemas.

Examples:

CLI usage

$ synthra import openapi.yaml --output schemas/

Ruby usage

importer = Synthra::OpenAPIImporter.new("openapi.yaml")
schemas = importer.import
schemas.each { |name, dsl| File.write("schemas/#{name}.dsl", dsl) }

Constant Summary collapse

TYPE_MAP =

OpenAPI type to Synthra type mapping

{
  # Strings
  "string" => "text",
  "string:email" => "email",
  "string:uri" => "url",
  "string:url" => "url",
  "string:uuid" => "uuid",
  "string:date" => "date",
  "string:date-time" => "timestamp",
  "string:password" => "password",
  "string:byte" => "text",
  "string:binary" => "text",
  "string:hostname" => "domain",
  "string:ipv4" => "ip",
  "string:ipv6" => "ipv6",

  # Numbers
  "integer" => "number",
  "integer:int32" => "number",
  "integer:int64" => "number",
  "number" => "float",
  "number:float" => "float",
  "number:double" => "float",

  # Boolean
  "boolean" => "boolean",

  # Complex
  "array" => "array",
  "object" => "object"
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(spec_path) ⇒ OpenAPIImporter

Create a new importer

Parameters:

  • spec_path (String)

    path to OpenAPI spec file



59
60
61
62
# File 'lib/synthra/openapi_importer.rb', line 59

def initialize(spec_path)
  @spec_path = spec_path
  @spec = load_spec
end

Instance Attribute Details

#specObject (readonly)

Returns the value of attribute spec.



53
54
55
# File 'lib/synthra/openapi_importer.rb', line 53

def spec
  @spec
end

#spec_pathObject (readonly)

Returns the value of attribute spec_path.



53
54
55
# File 'lib/synthra/openapi_importer.rb', line 53

def spec_path
  @spec_path
end

Instance Method Details

#components_schemasObject (private)



125
126
127
128
129
# File 'lib/synthra/openapi_importer.rb', line 125

def components_schemas
  @spec.dig("components", "schemas") || 
    @spec.dig("definitions") || # Swagger 2.0
    {}
end

#escape_regex(pattern) ⇒ Object (private)



218
219
220
# File 'lib/synthra/openapi_importer.rb', line 218

def escape_regex(pattern)
  pattern.gsub('"', '\\"')
end

#extract_ref_name(ref) ⇒ Object (private)



213
214
215
216
# File 'lib/synthra/openapi_importer.rb', line 213

def extract_ref_name(ref)
  # Handle #/components/schemas/User or #/definitions/User
  ref.split("/").last
end

#importHash<String, String>

Import all schemas from the OpenAPI spec

Returns:

  • (Hash<String, String>)

    schema name => DSL content



68
69
70
71
72
73
74
75
76
77
# File 'lib/synthra/openapi_importer.rb', line 68

def import
  schemas = {}

  components_schemas.each do |name, definition|
    dsl = schema_to_dsl(name, definition)
    schemas[name] = dsl if dsl
  end

  schemas
end

#import_to_directory(output_dir) ⇒ Array<String>

Import and write to directory

Parameters:

  • output_dir (String)

    output directory

Returns:

  • (Array<String>)

    paths to created files



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/synthra/openapi_importer.rb', line 84

def import_to_directory(output_dir)
  FileUtils.mkdir_p(output_dir)
  created = []

  base = File.expand_path(output_dir)
  import.each do |name, dsl|
    # SECURITY (path traversal): a schema name from the spec is attacker-controlled. basename
    # strips any "../" path components and the allowlist drops anything left, so the file can only
    # land inside output_dir; the expand_path assertion is a belt-and-suspenders backstop.
    safe = File.basename(to_snake_case(name.to_s)).gsub(/[^a-z0-9_]+/i, "_")
    # :nocov: defensive backstop — File.basename + the gsub allowlist always leave a non-empty
    # token for any non-empty schema name, so this empty-fallback is unreachable in practice.
    safe = "schema" if safe.empty?
    # :nocov:
    path = File.join(output_dir, "#{safe}.dsl")
    # :nocov: defensive backstop — basename already neutralizes "../" traversal, so this
    # expand_path containment check can never fail for a name that produced a safe basename.
    unless File.expand_path(path).start_with?(base + File::SEPARATOR)
      raise Synthra::Errors::SecurityError, "Refusing to write outside #{output_dir}: #{name.inspect}"
    end
    # :nocov:

    File.write(path, dsl)
    created << path
  end

  created
end

#load_specObject (private)



115
116
117
118
119
120
121
122
123
# File 'lib/synthra/openapi_importer.rb', line 115

def load_spec
  content = File.read(@spec_path)
  
  if @spec_path.end_with?(".json")
    JSON.parse(content)
  else
    YAML.safe_load(content, permitted_classes: [Date, Time])
  end
end

#property_to_type(prop_def) ⇒ Object (private)



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/synthra/openapi_importer.rb', line 149

def property_to_type(prop_def)
  type = prop_def["type"]
  format = prop_def["format"]

  # Handle references
  if prop_def["$ref"]
    ref = prop_def["$ref"]
    return extract_ref_name(ref)
  end

  # Handle enums
  if prop_def["enum"]
    values = prop_def["enum"].join(", ")
    return "enum(#{values})"
  end

  # Handle arrays
  if type == "array"
    items_type = property_to_type(prop_def["items"] || {})
    min_items = prop_def["minItems"] || 0
    max_items = prop_def["maxItems"] || 10
    return "array(#{items_type}, #{min_items}..#{max_items})"
  end

  # Handle nested objects
  if type == "object"
    if prop_def["properties"]
      # Inline object - use object type
      return "object"
    end
    return "object"
  end

  # Check for string constraints
  if type == "string"
    # Check for specific patterns
    if (pattern = prop_def["pattern"])
      return "regex(\"#{escape_regex(pattern)}\")"
    end
    
    # Check for length constraints
    min_len = prop_def["minLength"]
    max_len = prop_def["maxLength"]
    if min_len || max_len
      range = "#{min_len || 1}..#{max_len || 100}"
      return "text(#{range})"
    end
  end

  # Check for number constraints
  if %w[integer number].include?(type)
    min_val = prop_def["minimum"]
    max_val = prop_def["maximum"]
    if min_val || max_val
      range = "#{min_val || 0}..#{max_val || 1000}"
      return "number(#{range})"
    end
  end

  # Look up in type map
  key = format ? "#{type}:#{format}" : type
  TYPE_MAP[key] || TYPE_MAP[type] || "text"
end

#schema_to_dsl(name, definition) ⇒ Object (private)



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/synthra/openapi_importer.rb', line 131

def schema_to_dsl(name, definition)
  return nil unless definition["type"] == "object" || definition["properties"]

  lines = ["#{name}:"]
  properties = definition["properties"] || {}
  required = definition["required"] || []

  properties.each do |prop_name, prop_def|
    type = property_to_type(prop_def)
    optional = required.include?(prop_name) ? "" : "?"
    nullable = prop_def["nullable"] ? "?" : ""
    
    lines << "  #{prop_name}#{optional}: #{type}#{nullable}"
  end

  lines.join("\n")
end

#to_snake_case(str) ⇒ Object (private)



222
223
224
225
226
# File 'lib/synthra/openapi_importer.rb', line 222

def to_snake_case(str)
  str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
     .gsub(/([a-z\d])([A-Z])/, '\1_\2')
     .downcase
end