Module: OpenLoam::OpenApi

Defined in:
lib/open_loam/open_api.rb

Overview

Auto-generates an OpenAPI 3.1 document for the app's JSON API by INTROSPECTING what OpenLoam already knows — the generated Api::<Plural>Controllers, each entity's columns/types, its exposed FIELDS, its custom fields, and that every endpoint is bearer-authenticated and tenant-scoped. No hand-written annotations, no external gem.

OpenLoam::OpenApi.document   # => a Hash conforming to OpenAPI 3.1
OpenLoam::OpenApi.markdown   # => a Markdown rendering

The document describes the SHAPE of the API only — never tenant data. It is served at /admin/api_docs and exported by bin/rails open_loam:openapi:export.

Constant Summary collapse

TYPE_MAP =
{
  "string" => { "type" => "string" }, "text" => { "type" => "string" },
  "integer" => { "type" => "integer" }, "bigint" => { "type" => "integer" },
  "float" => { "type" => "number" }, "decimal" => { "type" => "number" },
  "boolean" => { "type" => "boolean" },
  "date" => { "type" => "string", "format" => "date" },
  "datetime" => { "type" => "string", "format" => "date-time" },
  "json" => { "type" => "object" }, "jsonb" => { "type" => "object" }
}.freeze
TENANCY_NOTE =
"Every endpoint is tenant-scoped by the bearer token: a caller only ever reads or " \
"writes data in the token's OWN tenant — cross-tenant access is impossible.".freeze

Class Method Summary collapse

Class Method Details

.api_entities ⇒ Object

The OpenLoam entities that have a generated JSON API controller. Anonymous subclasses (Class.new(OpenLoam::TenantRecord), common in tests) have no name, so model_name would raise "Class name cannot be blank" — skip them: a nameless class has no controller or route to document anyway.



47
48
49
50
51
52
53
# File 'lib/open_loam/open_api.rb', line 47

def api_entities
  Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application)
  OpenLoam::TenantRecord.descendants
                    .reject { |model| model.name.blank? }
                    .select { |model| controller_for(model) }
                    .sort_by(&:name)
end

.array_response(ref) ⇒ Object



182
183
184
# File 'lib/open_loam/open_api.rb', line 182

def array_response(ref)
  { "description" => "ok", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => ref } } } }
end

.column_schema(model, field) ⇒ Object



156
157
158
159
160
161
# File 'lib/open_loam/open_api.rb', line 156

def column_schema(model, field)
  column = model.columns_hash[field.to_s]
  return { "type" => "string" } unless column # a custom field or virtual

  TYPE_MAP.fetch(column.type.to_s, { "type" => "string" }).dup
end

.controller_for(model) ⇒ Object



55
56
57
58
59
# File 'lib/open_loam/open_api.rb', line 55

def controller_for(model)
  # model_name.plural handles uncountables ("equipment"), unlike route_key
  # (which becomes "equipment_index").
  "Api::#{model.model_name.plural.camelize}Controller".safe_constantize
end

.custom_field_names(model) ⇒ Object



163
164
165
166
167
168
169
# File 'lib/open_loam/open_api.rb', line 163

def custom_field_names(model)
  return [] unless model.respond_to?(:custom_field_definitions) && OpenLoam::Current.tenant

  model.custom_field_definitions.map(&:name)
rescue StandardError
  []
end

.document ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
# File 'lib/open_loam/open_api.rb', line 31

def document
  {
    "openapi" => "3.1.0",
    "info" => info,
    "servers" => [ { "url" => "/api" } ],
    "security" => [ { "bearerAuth" => [] } ],
    "components" => { "securitySchemes" => security_schemes, "schemas" => schemas },
    "paths" => paths,
    "x-tenancy" => TENANCY_NOTE
  }
end

.entity_schema(model) ⇒ Object



82
83
84
85
86
87
88
# File 'lib/open_loam/open_api.rb', line 82

def entity_schema(model)
  props = { "id" => { "type" => "integer", "readOnly" => true } }
  exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
  custom_field_names(model).each { |name| props[name] = { "type" => "string", "description" => "custom field" } }
  %w[created_at updated_at].each { |ts| props[ts] = { "type" => "string", "format" => "date-time", "readOnly" => true } }
  { "type" => "object", "properties" => props }
end

.error_response(desc) ⇒ Object



186
187
188
# File 'lib/open_loam/open_api.rb', line 186

def error_response(desc)
  { "description" => desc, "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "error" => { "type" => "string" } } } } } }
end

.exposed_fields(model) ⇒ Object

---- internals ----



141
142
143
144
145
146
147
148
149
150
# File 'lib/open_loam/open_api.rb', line 141

def exposed_fields(model)
  controller = controller_for(model)
  return controller::FIELDS.map(&:to_s) if controller.const_defined?(:FIELDS)

  # No declared FIELDS: fall back to columns, but NEVER surface an encrypted
  # column or its blind-index `_hash` (same exclusion as OpenLoam::Export).
  encrypted = model.respond_to?(:open_loam_encrypted_attributes) ? model.open_loam_encrypted_attributes : []
  blind = model.respond_to?(:open_loam_searchable_encrypted_attributes) ? model.open_loam_searchable_encrypted_attributes.map { |a| "#{a}_hash" } : []
  model.column_names - plumbing(model) - encrypted - blind
end

.id_param ⇒ Object



190
191
192
# File 'lib/open_loam/open_api.rb', line 190

def id_param
  { "name" => "id", "in" => "path", "required" => true, "schema" => { "type" => "integer" } }
end

.info ⇒ Object



61
62
63
64
65
66
67
68
# File 'lib/open_loam/open_api.rb', line 61

def info
  name = defined?(Rails) ? Rails.application.class.module_parent_name : "OpenLoam"
  {
    "title" => "#{name} API",
    "version" => "1.0.0",
    "description" => "Auto-generated from the OpenLoam entities. Bearer-token authenticated. #{TENANCY_NOTE}"
  }
end

.input_schema(model) ⇒ Object

Request body: the writable, declared fields only — never id/tenant_id/ timestamps. Field-level write access is enforced per the token's role at runtime (OpenLoam::Policy), which a structural schema can't express per-role; noted in the description rather than emitting a schema per role.



94
95
96
97
98
99
100
101
102
103
# File 'lib/open_loam/open_api.rb', line 94

def input_schema(model)
  props = {}
  exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
  custom_field_names(model).each { |name| props[name] = { "type" => "string" } }
  {
    "type" => "object",
    "properties" => props,
    "description" => "Field-level write access applies per the token's role — a field the role may not write is ignored."
  }
end

.markdown(doc = document) ⇒ Object

---- markdown ----



126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/open_loam/open_api.rb', line 126

def markdown(doc = document)
  lines = [ "# #{doc['info']['title']}", "", doc["info"]["description"], "", "**Auth:** bearer token. **Tenancy:** #{doc['x-tenancy']}", "" ]
  doc["paths"].sort.each do |path, ops|
    ops.each do |method, op|
      next unless op.is_a?(Hash) && op["summary"]
      lines << "## `#{method.upcase} /api#{path}` — #{op['summary']}"
      lines << "Requires a bearer token. Responses: #{op['responses'].keys.join(', ')}."
      lines << ""
    end
  end
  lines.join("\n")
end

.object_response(ref) ⇒ Object



180
# File 'lib/open_loam/open_api.rb', line 180

def object_response(ref) = { "description" => "ok", "content" => { "application/json" => { "schema" => ref } } }

.operation(summary, body: nil, **responses) ⇒ Object



171
172
173
174
175
176
177
178
# File 'lib/open_loam/open_api.rb', line 171

def operation(summary, body: nil, **responses)
  responses = { "401" => error_response("missing or invalid token") }.merge(responses)
  op = { "summary" => summary, "responses" => responses }
  if body
    op["requestBody"] = { "required" => true, "content" => { "application/json" => { "schema" => body } } }
  end
  op
end

.paths ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/open_loam/open_api.rb', line 105

def paths
  api_entities.each_with_object({}) do |model, out|
    plural = model.model_name.plural
    ref = { "$ref" => "#/components/schemas/#{model.name}" }
    input = { "$ref" => "#/components/schemas/#{model.name}Input" }

    out["/#{plural}"] = {
      "get" => operation("List #{plural}", "200" => array_response(ref)),
      "post" => operation("Create a #{model.name}", body: input, "201" => object_response(ref), "422" => error_response("validation failed"))
    }
    out["/#{plural}/{id}"] = {
      "parameters" => [ id_param ],
      "get" => operation("Fetch a #{model.name}", "200" => object_response(ref), "404" => error_response("not found")),
      "patch" => operation("Update a #{model.name}", body: input, "200" => object_response(ref), "422" => error_response("validation failed"), "404" => error_response("not found")),
      "delete" => operation("Soft-delete a #{model.name}", "204" => { "description" => "deleted" }, "404" => error_response("not found"))
    }
  end
end

.plumbing(model) ⇒ Object



152
153
154
# File 'lib/open_loam/open_api.rb', line 152

def plumbing(model)
  %w[id tenant_id created_at updated_at lock_version deleted_at custom_fields]
end

.schemas ⇒ Object



75
76
77
78
79
80
# File 'lib/open_loam/open_api.rb', line 75

def schemas
  api_entities.each_with_object({}) do |model, out|
    out[model.name] = entity_schema(model)
    out["#{model.name}Input"] = input_schema(model)
  end
end

.security_schemes ⇒ Object



70
71
72
73
# File 'lib/open_loam/open_api.rb', line 70

def security_schemes
  { "bearerAuth" => { "type" => "http", "scheme" => "bearer",
                      "description" => "A OpenLoam::ApiToken — identifies one user in one tenant." } }
end