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
-
.api_entities ⇒ Object
The OpenLoam entities that have a generated JSON API controller.
- .array_response(ref) ⇒ Object
- .column_schema(model, field) ⇒ Object
- .controller_for(model) ⇒ Object
- .custom_field_names(model) ⇒ Object
- .document ⇒ Object
- .entity_schema(model) ⇒ Object
- .error_response(desc) ⇒ Object
-
.exposed_fields(model) ⇒ Object
---- internals ----.
- .id_param ⇒ Object
- .info ⇒ Object
-
.input_schema(model) ⇒ Object
Request body: the writable, declared fields only — never id/tenant_id/ timestamps.
-
.markdown(doc = document) ⇒ Object
---- markdown ----.
- .object_response(ref) ⇒ Object
- .operation(summary, body: nil, **responses) ⇒ Object
- .paths ⇒ Object
- .plumbing(model) ⇒ Object
- .schemas ⇒ Object
- .security_schemes ⇒ Object
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 |