completed SMS gateway project
This commit is contained in:
326
JSONB_FIXES.md
Normal file
326
JSONB_FIXES.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# JSONB Field Fixes - Complete Solution
|
||||
|
||||
## Issue
|
||||
|
||||
```
|
||||
undefined method 'stringify_keys' for an instance of String
|
||||
```
|
||||
|
||||
This error occurred when rendering views that use Rails helpers like `button_to`, `link_to`, or `form_with` that serialize model objects containing JSONB fields.
|
||||
|
||||
## Root Cause
|
||||
|
||||
PostgreSQL JSONB columns can sometimes return inconsistent data types:
|
||||
- Sometimes returns a Hash (correct)
|
||||
- Sometimes returns a String (incorrect - causes stringify_keys error)
|
||||
- Sometimes returns nil (causes nil errors)
|
||||
|
||||
This happens when Rails tries to serialize model objects for URL generation or form helpers.
|
||||
|
||||
## Solution Applied
|
||||
|
||||
Added explicit JSONB attribute handling to ALL models with JSONB fields to ensure they always return Hash objects.
|
||||
|
||||
---
|
||||
|
||||
## Models Fixed
|
||||
|
||||
### 1. ApiKey Model - `permissions` field
|
||||
|
||||
**File**: `app/models/api_key.rb`
|
||||
|
||||
**JSONB Field**: `permissions` (stores API key permissions like send_sms, receive_sms, etc.)
|
||||
|
||||
**Fix Applied**:
|
||||
```ruby
|
||||
class ApiKey < ApplicationRecord
|
||||
# Normalize permissions to always be a Hash
|
||||
attribute :permissions, :jsonb, default: {}
|
||||
|
||||
before_validation :ensure_permissions_is_hash
|
||||
|
||||
# ... rest of the model ...
|
||||
|
||||
private
|
||||
|
||||
def ensure_permissions_is_hash
|
||||
self.permissions = {} if permissions.nil?
|
||||
self.permissions = {} unless permissions.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Gateway Model - `metadata` field
|
||||
|
||||
**File**: `app/models/gateway.rb`
|
||||
|
||||
**JSONB Field**: `metadata` (stores additional gateway configuration and metadata)
|
||||
|
||||
**Fix Applied**:
|
||||
```ruby
|
||||
class Gateway < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
|
||||
# ... rest of the model ...
|
||||
|
||||
private
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. OtpCode Model - `metadata` field
|
||||
|
||||
**File**: `app/models/otp_code.rb`
|
||||
|
||||
**JSONB Field**: `metadata` (stores OTP-related metadata like device info, IP address context, etc.)
|
||||
|
||||
**Fix Applied**:
|
||||
```ruby
|
||||
class OtpCode < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
|
||||
# ... rest of the model ...
|
||||
|
||||
private
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. SmsMessage Model - `metadata` field
|
||||
|
||||
**File**: `app/models/sms_message.rb`
|
||||
|
||||
**JSONB Field**: `metadata` (stores message metadata like delivery reports, carrier info, etc.)
|
||||
|
||||
**Fix Applied**:
|
||||
```ruby
|
||||
class SmsMessage < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
|
||||
# ... rest of the model ...
|
||||
|
||||
private
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
All JSONB columns are properly defined in the schema with default values:
|
||||
|
||||
```ruby
|
||||
# db/schema.rb
|
||||
|
||||
create_table "api_keys" do |t|
|
||||
# ...
|
||||
t.jsonb "permissions", default: {}
|
||||
# ...
|
||||
end
|
||||
|
||||
create_table "gateways" do |t|
|
||||
# ...
|
||||
t.jsonb "metadata", default: {}
|
||||
# ...
|
||||
end
|
||||
|
||||
create_table "otp_codes" do |t|
|
||||
# ...
|
||||
t.jsonb "metadata", default: {}
|
||||
# ...
|
||||
end
|
||||
|
||||
create_table "sms_messages" do |t|
|
||||
# ...
|
||||
t.jsonb "metadata", default: {}
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How The Fix Works
|
||||
|
||||
### 1. Explicit Attribute Declaration
|
||||
```ruby
|
||||
attribute :permissions, :jsonb, default: {}
|
||||
```
|
||||
This tells ActiveRecord to explicitly treat the column as JSONB and always default to an empty Hash.
|
||||
|
||||
### 2. Before Validation Callback
|
||||
```ruby
|
||||
before_validation :ensure_permissions_is_hash
|
||||
```
|
||||
Runs before every validation, ensuring the field is always a Hash before Rails processes it.
|
||||
|
||||
### 3. Hash Normalization Method
|
||||
```ruby
|
||||
def ensure_permissions_is_hash
|
||||
self.permissions = {} if permissions.nil?
|
||||
self.permissions = {} unless permissions.is_a?(Hash)
|
||||
end
|
||||
```
|
||||
- Converts nil to {}
|
||||
- Converts any non-Hash value to {}
|
||||
- Leaves Hash values unchanged
|
||||
|
||||
---
|
||||
|
||||
## Why This Prevents stringify_keys Error
|
||||
|
||||
When Rails helpers like `button_to` serialize model objects:
|
||||
|
||||
**Before Fix**:
|
||||
```ruby
|
||||
api_key.permissions # Sometimes returns String "{"send_sms": true}"
|
||||
# Rails tries to call .stringify_keys on String
|
||||
# ERROR: undefined method 'stringify_keys' for String
|
||||
```
|
||||
|
||||
**After Fix**:
|
||||
```ruby
|
||||
api_key.permissions # Always returns Hash {"send_sms" => true}
|
||||
# Rails successfully calls .stringify_keys on Hash
|
||||
# SUCCESS: No error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Safety Measures
|
||||
|
||||
In addition to model fixes, views also have defensive coding:
|
||||
|
||||
**app/views/admin/api_keys/index.html.erb**:
|
||||
```erb
|
||||
<% perms = api_key.permissions || {} %>
|
||||
<% perms = {} unless perms.is_a?(Hash) %>
|
||||
<% if perms.any? %>
|
||||
<% perms.select { |_, v| v }.keys.each do |perm| %>
|
||||
<span><%= perm.to_s.humanize %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span>None</span>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
This provides double protection:
|
||||
1. Model ensures JSONB field is always Hash
|
||||
2. View verifies and provides fallback
|
||||
|
||||
---
|
||||
|
||||
## Testing The Fix
|
||||
|
||||
### Console Test
|
||||
```bash
|
||||
bin/rails console
|
||||
```
|
||||
|
||||
```ruby
|
||||
# Test ApiKey permissions
|
||||
api_key = ApiKey.first
|
||||
api_key.permissions.class # => Hash
|
||||
api_key.can?("send_sms") # => true or false
|
||||
|
||||
# Test Gateway metadata
|
||||
gateway = Gateway.first
|
||||
gateway.metadata.class # => Hash
|
||||
gateway.metadata["foo"] = "bar"
|
||||
gateway.save! # Should work without errors
|
||||
|
||||
# Test OtpCode metadata
|
||||
otp = OtpCode.first
|
||||
otp.metadata.class # => Hash
|
||||
|
||||
# Test SmsMessage metadata
|
||||
msg = SmsMessage.first
|
||||
msg.metadata.class # => Hash
|
||||
```
|
||||
|
||||
### Server Test
|
||||
```bash
|
||||
# Start server
|
||||
bin/dev
|
||||
|
||||
# Visit admin interface
|
||||
# http://localhost:3000/admin/login
|
||||
# Login and navigate to API Keys
|
||||
# Should load without stringify_keys error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
✅ **Consistent**: All JSONB fields behave the same way across all models
|
||||
✅ **Safe**: Handles nil, String, and other edge cases gracefully
|
||||
✅ **Performance**: Minimal overhead (callback only runs on save/update)
|
||||
✅ **Rails-native**: Uses Rails attribute API, not monkey-patching
|
||||
✅ **Future-proof**: Works with all Rails helpers and serializers
|
||||
✅ **Maintainable**: Clear, documented pattern that's easy to understand
|
||||
|
||||
---
|
||||
|
||||
## Adding JSONB Fields in the Future
|
||||
|
||||
When adding new JSONB fields to any model, follow this pattern:
|
||||
|
||||
```ruby
|
||||
class MyModel < ApplicationRecord
|
||||
# 1. Declare the attribute
|
||||
attribute :my_jsonb_field, :jsonb, default: {}
|
||||
|
||||
# 2. Add before_validation callback
|
||||
before_validation :ensure_my_jsonb_field_is_hash
|
||||
|
||||
# ... rest of model code ...
|
||||
|
||||
private
|
||||
|
||||
# 3. Add normalization method
|
||||
def ensure_my_jsonb_field_is_hash
|
||||
self.my_jsonb_field = {} if my_jsonb_field.nil?
|
||||
self.my_jsonb_field = {} unless my_jsonb_field.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Fixed Models**: ApiKey, Gateway, OtpCode, SmsMessage
|
||||
✅ **Fixed Fields**: permissions (ApiKey), metadata (Gateway, OtpCode, SmsMessage)
|
||||
✅ **Error Resolved**: stringify_keys error when using button_to, form_with, etc.
|
||||
✅ **Approach**: Explicit attribute declaration + before_validation normalization
|
||||
✅ **Safety**: Double protection (model + view defensive coding)
|
||||
|
||||
All JSONB fields now consistently return Hash objects, preventing serialization errors throughout the Rails application!
|
||||
Reference in New Issue
Block a user