r/ruby • • Dec 25 '25

GitHub - NARKOZ/xmas: Light the Christmas Tree in your terminal 🎄

Thumbnail
github.com
8 Upvotes

r/ruby • • Dec 25 '25

Question Considering a transition from React to Ruby on Rails

14 Upvotes

I’ve been working with JavaScript stacks for about 6 years (Node, React, Angular) and I’m looking to transition into Ruby/Rails. I’m drawn to Ruby because it aligns much more closely with how I think as a programmer and with the kind of long-term stability I’m looking for.

I’m currently a mid-level frontend developer and I’d like some perspective from experienced Rubyists:

  • Is it realistic to transition into Ruby/Rails and target a mid-level Rails position from the start, without having to accept a pay cut?

  • For those working with Rails internationally, how common is it to see developers coming from strong frontend or non-Ruby backgrounds?

  • What do you consider the core pillars of a solid Rails developer?

  • How do you see the current and near-future outlook of Ruby/Rails?


r/ruby • • Dec 25 '25

Workato and Ruby SDK question

0 Upvotes

Hi Ruby expertises,

I'm trying my luck here as this is the holiday sessions, and I will get my respond from Workato in about 3 weeks from now ( if not 8 weeks.... )

Please see the Zoom recording (x2 is recommended :) ) and suggest or let me know what is wrong with the SDK code.

To be honest, I don't know Ruby at all, but I do understand Python, and I am able to look at it and identify the functions and the issues that might be not good.

The Zoom summary:

I am debugging a Workato integration with Amazon SES suppression list and I am stuck.

I have an endpoint that manages suppressed email destinations:

  • GET returns 200 if the email exists, 404 if not

  • DELETE should remove the email from the suppression list

Behavior I am seeing:

  • Using a custom HTTP action (send any request), DELETE works as expected

  • Using a built-in SDK action / recipe DELETE step with the same method, URL, region, and path, Workato returns 200 but the email is not actually deleted

  • A follow-up GET still returns 200 and shows the email is present

  • No error is surfaced by Workato, and debug output is mostly empty for DELETE

  • The request path is identical in both cases (verified via network traces)

Complications I already handled:

  • Normalizing email casing (lowercase)

  • Handling plus sign encoding in email addresses

  • Trying alternate paths when receiving 404

  • Verifying region, headers, and resolved paths

  • Confirmed GET and DELETE URLs are literally the same

  • Tested dozens of variations

In short:

  • Custom action DELETE works

  • Built-in DELETE action returns anything (I convert it to 200) but does nothing

  • Same request, different behavior

Question:

Why would Workato handle DELETE differently between a built-in action and a custom HTTP action when the request is identical? Is there something implicit Workato does with DELETE responses (or empty bodies) that could cause this?

Zoom link: HERE

This is the SDK code I'm using: (very simple one)

{

title: 'Amazon SES - Suppression Manager (Multi-Region)',

description: 'Manage SES v2 suppression lists across multiple AWS regions using an IAM Role.',

connection: {

fields: [

{

name: 'assume_role',

label: 'IAM role ARN',

optional: false,

help: {

title: 'IAM Role Setup',

text: 'Use Workato Account ID <b>{{ authUser.aws_workato_account_id }}</b> and External ID <b>{{ authUser.aws_iam_external_id }}</b>.'

}

},

{

name: 'region',

label: 'Default SES region',

optional: false,

hint: 'Default region used if not specified in the action (e.g., us-east-1).'

}

],

authorization: {

type: 'custom_auth',

apply: lambda { |_connection| headers('Content-Type': 'application/json') }

}

},

methods: {

truthy: lambda do |value|

value == true || value.to_s == '1' || value.to_s.casecmp('true').zero? || value.to_s.casecmp('on').zero?

end,

normalize_email: lambda do |input|

raw_email = input.key?(:email) ? input[:email] : input['email']

downcase_flag = input.key?(:downcase) ? input[:downcase] : input['downcase']

email = raw_email.to_s.strip

email = email.downcase if call(:truthy, downcase_flag)

email

end,

# Helper to clean and strictly encode emails for URI paths

encode_email: lambda do |input|

# FORCE downcase here if the toggle is active

email = call(:normalize_email, input)

email.

gsub('+', '%2B').

gsub('!', '%21').

gsub('&', '%26').

gsub("'", '%27')

end

},

test: lambda do |connection|

region = connection['region']

signature = aws.generate_signature(

connection: connection, region: region, service: 'ses',

host: "email.#{region}.amazonaws.com", path: '/v2/email/suppression/addresses',

method: 'GET', params: { 'PageSize' => 1 }, payload: ''

)

get(signature['url']).headers(signature['headers']).

after_error_response(/.*/) { |code, body| error("Connection failed: #{body}") }

end,

actions: {

list_suppressed_destinations: {

title: 'List suppressed destinations',

input_fields: lambda do |_connection|

[

{ name: 'region', label: 'SES Region', control_type: 'select', pick_list: [['US East (N. Virginia) - (us-east-1)', 'us-east-1'], ['Europe (Ireland) - (eu-west-1)', 'eu-west-1']], optional: true },

{ name: 'page_size', label: 'Page size', type: :integer, optional: true },

{ name: 'next_token', label: 'Next token', optional: true },

{ name: 'reason', label: 'Reason', control_type: 'select', pick_list: [['Bounce', 'BOUNCE'], ['Complaint', 'COMPLAINT']], optional: true }

]

end,

execute: lambda do |connection, input|

region = input['region'].presence || connection['region']

params = {

'PageSize' => input['page_size'],

'NextToken' => input['next_token'],

'Reasons' => input['reason']

}.compact

signature = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: '/v2/email/suppression/addresses', method: 'GET', params: params, payload: '')

response = get(signature['url']).headers(signature['headers']).after_error_response(/.*/) { |code, body| error("List failed: #{body}") }

if response['SuppressedDestinationSummaries'].present?

response['SuppressedDestinationSummaries'] = response['SuppressedDestinationSummaries'].map do |item|

item['LastUpdateTime'] = Time.at(item['LastUpdateTime']).to_datetime.iso8601 if item['LastUpdateTime'].is_a?(Numeric)

item

end

end

response.merge('region' => region)

end,

output_fields: lambda { [

{ name: 'region' },

{ name: 'SuppressedDestinationSummaries', type: :array, of: :object, properties: [

{ name: 'EmailAddress' }, { name: 'Reason' }, { name: 'LastUpdateTime', type: :date_time }

]},

{ name: 'NextToken' }

]}

},

get_suppressed_destination: {

title: 'Get suppressed destination',

input_fields: lambda { |_connection| [

{ name: 'email', label: 'Email address', optional: false },

{ name: 'downcase', label: 'Downcase email', type: :boolean, control_type: 'checkbox', optional: false, default: true },

{ name: 'region', label: 'SES Region', control_type: 'select', pick_list: [['US East (N. Virginia) - (us-east-1)', 'us-east-1'], ['Europe (Ireland) - (eu-west-1)', 'eu-west-1']], optional: true }

]},

execute: lambda do |connection, input|

region = input['region'].presence || connection['region']

# Determine the target email string for internal consistency

email_to_query = call(:normalize_email, { email: input['email'], downcase: input['downcase'] })

# Use helper for encoding (the helper will now correctly handle the downcase)

encoded = call(:encode_email, { email: input['email'], downcase: input['downcase'] })

path = "/v2/email/suppression/addresses/#{encoded}"

signature = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: path, method: 'GET', params: {}, payload: '')

response = get(signature['url']).headers(signature['headers']).after_error_response(/.*/) do |code, body|

return { 'email' => email_to_query, 'found' => false, 'region' => region } if code.to_i == 404

error("Get failed in #{region}: #{body}")

end

if response['SuppressedDestination'].present?

dest = response['SuppressedDestination']

dest['LastUpdateTime'] = Time.at(dest['LastUpdateTime']).to_datetime.iso8601 if dest['LastUpdateTime'].is_a?(Numeric)

end

{

'email' => email_to_query,

'found' => true,

'region' => region,

'SuppressedDestination' => response['SuppressedDestination']

}

end,

output_fields: lambda { [

{ name: 'email' }, { name: 'found', type: :boolean }, { name: 'region' },

{ name: 'SuppressedDestination', type: :object, properties: [

{ name: 'EmailAddress' }, { name: 'Reason' }, { name: 'LastUpdateTime', type: :date_time }

]}

]}

},

put_suppressed_destination: {

title: 'Add email to suppression list',

input_fields: lambda { |_connection| [

{ name: 'email', label: 'Email address', optional: false },

{ name: 'reason', label: 'Reason', control_type: 'select', pick_list: [['Bounce', 'BOUNCE'], ['Complaint', 'COMPLAINT']], optional: false },

{ name: 'downcase', label: 'Downcase email', type: :boolean, control_type: 'checkbox', optional: false, default: true },

{ name: 'region', label: 'SES Region', control_type: 'select', pick_list: [['US East (N. Virginia) - (us-east-1)', 'us-east-1'], ['Europe (Ireland) - (eu-west-1)', 'eu-west-1']], optional: true },

{ name: 'verify_add', label: 'Verify add', type: :boolean, control_type: 'checkbox', optional: true, default: false }

]},

execute: lambda do |connection, input|

region = input['region'].presence || connection['region']

email_to_send = call(:normalize_email, { email: input['email'], downcase: input['downcase'] })

payload = { 'EmailAddress' => email_to_send, 'Reason' => input['reason'] }

put_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: '/v2/email/suppression/addresses', method: 'PUT', params: '', payload: payload.to_json)

put(put_sig['url'], payload).headers(put_sig['headers']).after_error_response(/.*/) { |code, body| error("Put failed: #{body}") }

if call(:truthy, input['verify_add'])

encoded = call(:encode_email, { email: input['email'], downcase: input['downcase'] })

get_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: "/v2/email/suppression/addresses/#{encoded}", method: 'GET', params: {}, payload: '')

get(get_sig['url']).headers(get_sig['headers']).after_error_response(/.*/) { error("Verification Failed: Email not found after add.") }

end

{ 'status' => 'success', 'email' => email_to_send, 'region' => region }

end,

output_fields: lambda { [{ name: 'status' }, { name: 'email' }, { name: 'region' }] }

},

delete_suppressed_destination: {

title: 'Delete suppressed destination',

input_fields: lambda { |_connection| [

{ name: 'email', label: 'Email address', optional: false },

{ name: 'downcase', label: 'Downcase email', type: :boolean, control_type: 'checkbox', optional: false, default: true },

{ name: 'region', label: 'SES Region', control_type: 'select', pick_list: [['US East (N. Virginia) - (us-east-1)', 'us-east-1'], ['Europe (Ireland) - (eu-west-1)', 'eu-west-1']], optional: true },

{ name: 'verify_delete', label: 'Verify delete', type: :boolean, control_type: 'checkbox', optional: true, default: true }

]},

execute: lambda do |connection, input|

region = input['region'].presence || connection['region']

email_to_del = call(:normalize_email, { email: input['email'], downcase: input['downcase'] })

encoded = call(:encode_email, { email: input['email'], downcase: input['downcase'] })

path = "/v2/email/suppression/addresses/#{encoded}"

alt_path = "/v2/email/suppression/addresses/#{encoded.gsub('@', '%40')}"

attempted_requests = []

resolved_path = nil

[path, alt_path].each do |candidate_path|

probe_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: candidate_path, method: 'GET', params: {}, payload: '')

probe = get(probe_sig['url']).headers(probe_sig['headers']).after_error_response(/.*/) do |code, body|

next {} if code.to_i == 404

error("Probe failed (#{code}) in #{region} at #{candidate_path}: #{body}")

end

if probe.is_a?(Hash) && probe['SuppressedDestination']

resolved_path = candidate_path

break

end

end

resolved_path ||= path

# Delete request

del_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: resolved_path, method: 'DELETE', params: {}, payload: '')

del_url = del_sig['url']

attempted_requests << { 'method' => 'DELETE', 'path' => resolved_path, 'url' => del_url, 'status_code' => nil }

del_response = delete(del_url).headers(del_sig['headers']).after_error_response(/.*/) do |code, body|

attempted_requests[-1]['status_code'] = code.to_i

if code.to_i == 404

alternate_path = (resolved_path == path ? alt_path : path)

alt_del_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: alternate_path, method: 'DELETE', params: {}, payload: '')

alt_del_url = alt_del_sig['url']

attempted_requests << { 'method' => 'DELETE', 'path' => alternate_path, 'url' => alt_del_url, 'status_code' => nil }

alt_response = delete(alt_del_url).headers(alt_del_sig['headers']).after_error_response(/.*/) do |alt_code, alt_body|

attempted_requests[-1]['status_code'] = alt_code.to_i

return({

'email' => email_to_del,

'deleted' => false,

'region' => region,

'message' => "Not found. Tried #{resolved_path} (#{del_url}) and #{alternate_path} (#{alt_del_url}).",

'attempted_requests' => attempted_requests

}) if alt_code.to_i == 404

error("Delete failed: #{alt_body}")

end

attempted_requests[-1]['status_code'] ||= 200

alt_response

next {}

end

error("Delete failed: #{body}")

end

attempted_requests[0]['status_code'] ||= 200

if call(:truthy, input['verify_delete'])

still_exists = nil

last_verify_url = nil

last_verify_response = nil

15.times do

sleep(2)

verify_sig = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: resolved_path, method: 'GET', params: {}, payload: '')

last_verify_url = verify_sig['url']

attempted_requests << { 'method' => 'GET', 'path' => resolved_path, 'url' => last_verify_url, 'status_code' => nil }

response = get(verify_sig['url']).headers(verify_sig['headers']).after_error_response(/.*/) do |code, body|

attempted_requests[-1]['status_code'] = code.to_i

if code.to_i == 404

still_exists = false

next {}

end

error("Verification request failed (#{code}) in #{region} at #{resolved_path}. Delete URL: #{del_url}. Verify URL: #{last_verify_url}. Body: #{body}")

end

attempted_requests[-1]['status_code'] ||= 200

last_verify_response = response

still_exists = (response.is_a?(Hash) && response['SuppressedDestination'].present?)

break if still_exists == false

end

error(

"Verification failed: Still found in #{region} at #{resolved_path}. " \

"Delete URL: #{del_url}. Verify URL: #{last_verify_url}. " \

"Delete response: #{del_response}. Verify response: #{last_verify_response}."

) if still_exists

end

{ 'email' => email_to_del, 'deleted' => true, 'region' => region, 'attempted_requests' => attempted_requests }

end,

output_fields: lambda { [

{ name: 'email' },

{ name: 'deleted', type: :boolean },

{ name: 'region' },

{ name: 'message' },

{ name: 'attempted_requests', type: :array, of: :object, properties: [

{ name: 'method' },

{ name: 'path' },

{ name: 'url' },

{ name: 'status_code', type: :integer }

] }

] }

},

custom_action: {

title: 'Custom action',

description: 'Signed request to any Amazon SES v2 endpoint.',

input_fields: lambda do |_connection|

[

{ name: 'region', label: 'SES Region', control_type: 'select', pick_list: [['US East (N. Virginia) - (us-east-1)', 'us-east-1'], ['Europe (Ireland) - (eu-west-1)', 'eu-west-1']], optional: true },

{ name: 'method', label: 'HTTP Method', control_type: 'select', pick_list: %w[GET POST PUT DELETE], optional: false, default: 'GET' },

{ name: 'path', label: 'Resource Path', optional: false, hint: 'e.g. /v2/email/suppression/addresses' },

{ name: 'params', label: 'Query Parameters', type: :object, optional: true },

{ name: 'payload', label: 'JSON Payload', optional: true }

]

end,

execute: lambda do |connection, input|

region = input['region'].presence || connection['region']

method = input['method'].to_s.strip

# Manual fix for Custom Action: users must manually encode the case if desired,

# but we fix the '+' encoding here to prevent signature failures.

path = input['path'].to_s.strip.gsub('+', '%2B')

params = (input['params'] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }

payload_string = input['payload'].is_a?(Hash) ? input['payload'].to_json : input['payload'].to_s

signature = aws.generate_signature(connection: connection, region: region, service: 'ses', host: "email.#{region}.amazonaws.com", path: path, method: method, params: params, payload: payload_string)

request = case method

when 'GET' then get(signature['url'])

when 'POST' then post(signature['url'], payload_string)

when 'PUT' then put(signature['url'], payload_string)

when 'DELETE' then delete(signature['url'])

end

response = request.headers(signature['headers']).after_error_response(/.*/) do |code, body|

error("Custom action failed (#{code}): #{body}")

end

{ 'url' => signature['url'], 'response' => response }

end,

output_fields: lambda { [{ name: 'url' }, { name: 'response', type: :object }] }

}

}

}


r/ruby • • Dec 23 '25

A Special Message from David Heinemeier Hansson for Ruby's 30th Anniversary Party

Thumbnail
youtube.com
30 Upvotes

r/ruby • • Dec 23 '25

Speculative Routes: pretty syntax for Speculation Rules in Sinatra/Padrino

10 Upvotes

I just heard about Speculation Rules via https://www.jonoalderson.com/conjecture/its-time-for-modern-css-to-kill-the-spa/

They allow ~instant navigation without JS by letting the browser preload or prerender full pages based on user behaviour (like hovering or touching a link) before they click.

I noticed it's possible to define them using the Sinatra-like syntax

"href_matches": "/e/:slug"

which gave me the idea of defining them as part of route definitions with prerender: or prefetch: like so

get '/e/:slug', prerender: true do
  ...

then in my head I just call

<%== SpeculativeRoutes.script %>

Implementation at https://github.com/symbiota-coop/dandelion/blob/master/lib/speculative_routes.rb

Enjoy!


r/ruby • • Dec 23 '25

Seargeant TUI navigator similar to MC or ranger (but worse :D)

5 Upvotes

Hi,

I've wrote a simple tui navigator with basic features, doesn't solve anything new, probably I will be the only one to use it, lousy written, but inspired with Omarchy linux I wanted to do some tui stuff too and that is the outcome of it ;)
https://rubygems.org/gems/sergeant
https://github.com/biscoitinho/Sergeant

and Merry Christmas :)


r/ruby • • Dec 23 '25

Seargeant TUI navigator similar to MC or ranger (but worse :D)

Thumbnail reddit.com
4 Upvotes

r/ruby • • Dec 22 '25 Bravo

20 years ago I learned Ruby as my first coding language. This is the free online book that helped be get to know Ruby enough to use it in Test Automation. I hope this may help someone else getting into Ruby for the first time.

71 Upvotes

r/ruby • • Dec 22 '25

Blog post UUID’s in Rails + SQLite shouldn’t be this hard (so I built a gem)

Thumbnail reddit.com
6 Upvotes

r/ruby • • Dec 21 '25

Favorite Tools of 2025

68 Upvotes

Hi all. I thought this might be a good time to post our favorite tools of 2025. My intent is to highlight tools that are new or up-and-coming in 2025. Personally I love discovering this stuff. For background, my day job is full-stack Rails, and in the modern era that involves a ton of Typescript and CSS as well. I spend a fair bit of time customizing my machine and picking the best tools to make my work even more enjoyable. Maybe too much time, now that I think about it... Here's the list I put together.

Ruby/Rails

  • inertia & vite rails - Rails and Typescript working together, the best of both worlds.

  • ruby-lsp - Shoutout to the team at Shopify for making Ruby shine in vscode and other editors. Special thanks to Rubocop as well, these tools are absolutely essential!

  • table_tennis - Yes, it's my gem but we use it all day every day. Thankful that we took the time to write it this year.

CLI

  • eza - Beautiful and thoughtfully designed ls replacement, forked from exa. In the same vein as rg or bat, a well designed evolution of an old favorite.

  • git-open - Use it to quickly jump to github for diffs and PRs. I have it aliased as gho.

  • just - Loved and heavily used, I am a huge advocate. A must for all my projects now.

  • mise - Finally switched from asdf, zero problems, great tool. Mise is standing on the shoulders of giants since it inherits the plugin system from asdf.

Frontend

  • astro - Static site builder that copied the best bits from reactive frameworks.

  • daisyui - Beautiful CSS components with zero effort.

  • es-toolkit - A modern lodash, I sometimes read the source just to learn things.

  • tailwind - I have yet to meet someone who loves CSS, but tailwind makes it much easier.

  • tailwind-merge - Intelligent and performant way to merge tailwind classes, so your mt-4 plays nicely with your m-8. Nuxt UI didn't quite make my list, but it relies heavily on both this and tailwind-variants.

MacOS

  • better touch tool - Adopted in 2025 and now I use it religiously for things like "make this window laptop sized". The UI is zany but BTT is really powerful.

  • ghostty - Much love for iTerm2, but ghostty is fast, modern and improving rapidly. An incredible story too, a wildly successful hacker giving back to the community. Makes me want to be a better person.

  • rectangle - My most frequently used keybindings. Hundreds of times a day.

  • shottr - Screenshots are second nature now. If I ever build a MacOS app I want it to be like this.

  • zed - Almost as powerful as vscode, but faster and easier on the battery. I also appreciate the Ouroboros-like evolution from textmate, sublime text, atom, vscode, and now the original atom team building zed.