Module: Tina4::AutoCrud

Defined in:
lib/tina4/auto_crud.rb

Class Method Summary collapse

Class Method Details

.build_example(model_class) ⇒ Object

Build a sample request body from ORM field definitions.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/tina4/auto_crud.rb', line 42

def build_example(model_class)
  example = {}
  return example unless model_class.respond_to?(:field_definitions)

  model_class.field_definitions.each do |name, opts|
    next if opts[:primary_key] && opts[:auto_increment]

    case opts[:type]
    when :integer
      example[name.to_s] = 0
    when :numeric, :float, :decimal
      example[name.to_s] = 0.0
    when :boolean
      example[name.to_s] = true
    when :datetime
      example[name.to_s] = "2024-01-01T00:00:00"
    else
      example[name.to_s] = "string"
    end
  end
  example
end

.clear!Object Also known as: clear



262
263
264
265
# File 'lib/tina4/auto_crud.rb', line 262

def clear!
  @models = []
  @public_flags = {}
end

.discover(models_dir = "src/orm", prefix: "/api", public: false) ⇒ Array<String>

Discover ORM model classes from a directory and register them.

Parameters:

  • models_dir (String) (defaults to: "src/orm")

    directory to scan (default "src/orm")

  • prefix (String) (defaults to: "/api")

    URL prefix for generated routes (default "/api")

  • public (Boolean) (defaults to: false)

    when true, opt discovered models' write routes out of the secure-by-default gate (see .register). Default false keeps writes gated. Parity with tina4-python / tina4-php.

Returns:

  • (Array<String>)

    list of discovered model class names



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/tina4/auto_crud.rb', line 243

def discover(models_dir = "src/orm", prefix: "/api", public: false)
  discovered = []
  return discovered unless Dir.exist?(models_dir)

  Dir.glob(File.join(models_dir, "*.rb")).each do |file|
    require_relative File.expand_path(file)
  end

  # Find all ORM subclasses that have a table_name
  ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM rescue false }.each do |klass|
    next unless klass.respond_to?(:table_name) && klass.table_name
    register(klass, public: public)
    discovered << klass.name
  end

  generate_routes(prefix: prefix) unless discovered.empty?
  discovered
end

.generate_routes(prefix: "/api") ⇒ Object

Generate REST endpoints for all registered models



35
36
37
38
39
# File 'lib/tina4/auto_crud.rb', line 35

def generate_routes(prefix: "/api")
  models.each do |model_class|
    generate_routes_for(model_class, prefix: prefix, public: public_flags[model_class])
  end
end

.generate_routes_for(model_class, prefix: "/api", public: nil) ⇒ Object

Generate REST endpoints for a single model class.

Parameters:

  • model_class (Class)

    the ORM subclass to expose

  • prefix (String) (defaults to: "/api")

    URL prefix for the generated routes

  • public (Boolean, nil) (defaults to: nil)

    when true, the write routes (POST/PUT/DELETE) opt out of the secure-by-default gate via .no_auth. When nil (the default), the flag stored at register() time is used, falling back to false (secure). The read routes (GET) are never gated regardless.



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
99
100
101
102
103
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
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/tina4/auto_crud.rb', line 74

def generate_routes_for(model_class, prefix: "/api", public: nil)
  table = model_class.table_name
  pk = model_class.primary_key_field || :id
  pretty_name = table.to_s.split("_").map(&:capitalize).join(" ")
  example_body = build_example(model_class)

  # Explicit arg wins; otherwise fall back to the flag recorded by
  # register(), otherwise secure-by-default.
  is_public = public.nil? ? (public_flags[model_class] || false) : public

  # GET /api/{table} -- list all with pagination, filtering, sorting
  Tina4::Router.add("GET", "#{prefix}/#{table}", proc { |req, res|
    begin
      per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
      page     = (req.query["page"] || 1).to_i
      limit    = per_page
      offset   = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
      order_by = parse_sort(req.query["sort"])

      # Filter support: ?filter[field]=value
      filter_conditions = []
      filter_values = []
      req.query.each do |key, value|
        if key =~ /\Afilter\[(\w+)\]\z/
          filter_conditions << "#{$1} = ?"
          filter_values << value
        end
      end

      if filter_conditions.empty?
        records = model_class.all(limit: limit, offset: offset, order_by: order_by)
        total = model_class.count
      else
        where_clause = filter_conditions.join(" AND ")
        records = model_class.where(where_clause, filter_values)
        total = records.length
        # Apply manual pagination for filtered results
        records = records.slice(offset, limit) || []
      end

      res.json({
        data: records.map { |r| r.to_h },
        total: total,
        limit: limit,
        offset: offset
      })
    rescue => e
      res.json({ error: e.message }, status: 500)
    end
  }, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })

  # GET /api/{table}/{id} -- get single record
  Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
    begin
      id = req.params["id"]
      record = model_class.find_by_id(id.to_i)
      if record
        res.json({ data: record.to_h })
      else
        res.json({ error: "Not found" }, status: 404)
      end
    rescue => e
      res.json({ error: e.message }, status: 500)
    end
  }, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })

  # POST /api/{table} -- create record
  post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
    begin
      attributes = req.body_parsed
      record = model_class.create(attributes)
      if record.persisted?
        res.json({ data: record.to_h }, status: 201)
      else
        res.json({ errors: record.errors }, status: 422)
      end
    rescue => e
      res.json({ error: e.message }, status: 500)
    end
  }, swagger_meta: {
    summary: "Create #{pretty_name}",
    tags: [table.to_s],
    request_body: {
      "description" => "#{pretty_name} data",
      "required" => true,
      "content" => {
        "application/json" => {
          "schema" => { "type" => "object" },
          "example" => example_body
        }
      }
    }
  })

  # Secure-by-default: only opt out of the write gate when public: true.
  post_route.no_auth if is_public

  # PUT /api/{table}/{id} -- update record
  put_route = Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
    begin
      id = req.params["id"]
      record = model_class.find_by_id(id.to_i)
      unless record
        next res.json({ error: "Not found" }, status: 404)
      end

      attributes = req.body_parsed
      attributes.each do |key, value|
        setter = "#{key}="
        record.__send__(setter, value) if record.respond_to?(setter)
      end

      if record.save
        res.json({ data: record.to_h })
      else
        res.json({ errors: record.errors }, status: 422)
      end
    rescue => e
      res.json({ error: e.message }, status: 500)
    end
  }, swagger_meta: {
    summary: "Update #{pretty_name}",
    tags: [table.to_s],
    request_body: {
      "description" => "#{pretty_name} data",
      "required" => true,
      "content" => {
        "application/json" => {
          "schema" => { "type" => "object" },
          "example" => example_body
        }
      }
    }
  })

  # Secure-by-default: only opt out of the write gate when public: true.
  put_route.no_auth if is_public

  # DELETE /api/{table}/{id} -- delete record
  delete_route = Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
    begin
      id = req.params["id"]
      record = model_class.find_by_id(id.to_i)
      unless record
        next res.json({ error: "Not found" }, status: 404)
      end

      if record.delete
        res.json({ message: "Deleted" })
      else
        res.json({ error: "Delete failed" }, status: 500)
      end
    rescue => e
      res.json({ error: e.message }, status: 500)
    end
  }, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })

  # Secure-by-default: only opt out of the write gate when public: true.
  delete_route.no_auth if is_public
end

.modelsObject

Track registered model classes



8
9
10
# File 'lib/tina4/auto_crud.rb', line 8

def models
  @models ||= []
end

.public_flagsObject

Per-model public-writes flag (model_class => Boolean). Default secure; only set true when a model is registered public: true. Mirrors tina4-php's $this->public map. Write routes stay gated unless a model is explicitly opted out.



16
17
18
# File 'lib/tina4/auto_crud.rb', line 16

def public_flags
  @public_flags ||= {}
end

.register(model_class, public: false) ⇒ Object

Register a model for auto-CRUD.

Parameters:

  • model_class (Class)

    the ORM subclass to expose

  • public (Boolean) (defaults to: false)

    when true, the generated write routes (POST/PUT/DELETE) opt OUT of the framework's secure-by-default write gate — they are marked .no_auth. Default false keeps writes gated (a valid bearer token is required), matching the router's rule that POST/PUT/PATCH/DELETE require auth unless explicitly opened. Parity with tina4-python / tina4-php.



29
30
31
32
# File 'lib/tina4/auto_crud.rb', line 29

def register(model_class, public: false)
  models << model_class unless models.include?(model_class)
  public_flags[model_class] = public
end