|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -e |
| 3 | + |
| 4 | +# 1) Clean and create ruby/ structure |
| 5 | +rm -rf ruby |
| 6 | +mkdir -p ruby/{app/models,app/controllers,config,initializers,public} |
| 7 | + |
| 8 | +# 2) Gemfile |
| 9 | +cat > ruby/Gemfile << 'EOF' |
| 10 | +source 'https://rubygems.org' |
| 11 | +ruby '3.1.0' |
| 12 | +
|
| 13 | +gem 'sinatra', require: 'sinatra/base' |
| 14 | +gem 'mongoid', '~> 7.3' |
| 15 | +gem 'jwt' |
| 16 | +gem 'bcrypt' |
| 17 | +gem 'rack-cors' |
| 18 | +gem 'json' |
| 19 | +EOF |
| 20 | + |
| 21 | +# 3) config/mongoid.yml |
| 22 | +cat > ruby/config/mongoid.yml << 'EOF' |
| 23 | +development: |
| 24 | + clients: |
| 25 | + default: |
| 26 | + uri: <%= ENV['MONGODB_URI'] || 'mongodb://127.0.0.1:27017/tasknexus_dev' %> |
| 27 | +production: |
| 28 | + clients: |
| 29 | + default: |
| 30 | + uri: <%= ENV['MONGODB_URI'] %> |
| 31 | +EOF |
| 32 | + |
| 33 | +# 4) initializers/jwt.rb |
| 34 | +cat > ruby/initializers/jwt.rb << 'EOF' |
| 35 | +JWT_SECRET = ENV['JWT_SECRET'] || 'super$ecretKey' # change in prod! |
| 36 | +def issue_token(payload) |
| 37 | + JWT.encode(payload, JWT_SECRET, 'HS256') |
| 38 | +end |
| 39 | +def decode_token(token) |
| 40 | + JWT.decode(token, JWT_SECRET, true, algorithm: 'HS256')[0] |
| 41 | +rescue |
| 42 | + nil |
| 43 | +end |
| 44 | +EOF |
| 45 | + |
| 46 | +# 5) app/models/user.rb |
| 47 | +cat > ruby/app/models/user.rb << 'EOF' |
| 48 | +require 'mongoid' |
| 49 | +require 'bcrypt' |
| 50 | +
|
| 51 | +class User |
| 52 | + include Mongoid::Document |
| 53 | + include Mongoid::Timestamps |
| 54 | + field :username, type: String |
| 55 | + field :password_hash, type: String |
| 56 | +
|
| 57 | + validates :username, presence: true, uniqueness: true |
| 58 | + validates :password_hash, presence: true |
| 59 | +
|
| 60 | + def password=(raw) |
| 61 | + self.password_hash = BCrypt::Password.create(raw) |
| 62 | + end |
| 63 | +
|
| 64 | + def authenticate(raw) |
| 65 | + BCrypt::Password.new(password_hash) == raw |
| 66 | + end |
| 67 | +end |
| 68 | +EOF |
| 69 | + |
| 70 | +# 6) app/models/task.rb |
| 71 | +cat > ruby/app/models/task.rb << 'EOF' |
| 72 | +require 'mongoid' |
| 73 | +
|
| 74 | +class Task |
| 75 | + include Mongoid::Document |
| 76 | + include Mongoid::Timestamps |
| 77 | + field :title, type: String |
| 78 | + field :completed, type: Mongoid::Boolean, default: false |
| 79 | + field :position, type: Integer |
| 80 | +
|
| 81 | + belongs_to :user |
| 82 | +
|
| 83 | + validates :title, presence: true |
| 84 | +end |
| 85 | +EOF |
| 86 | + |
| 87 | +# 7) app/controllers/auth_controller.rb |
| 88 | +cat > ruby/app/controllers/auth_controller.rb << 'EOF' |
| 89 | +require 'sinatra/base' |
| 90 | +require 'json' |
| 91 | +require_relative '../models/user' |
| 92 | +require_relative '../../initializers/jwt' |
| 93 | +
|
| 94 | +class AuthController < Sinatra::Base |
| 95 | + post '/register' do |
| 96 | + data = JSON.parse(request.body.read) |
| 97 | + user = User.new(username: data['username']) |
| 98 | + user.password = data['password'] |
| 99 | + halt 400, { error: user.errors.full_messages }.to_json unless user.save |
| 100 | + token = issue_token({ user_id: user.id.to_s }) |
| 101 | + { token: token }.to_json |
| 102 | + end |
| 103 | +
|
| 104 | + post '/login' do |
| 105 | + data = JSON.parse(request.body.read) |
| 106 | + user = User.find_by(username: data['username']) rescue nil |
| 107 | + halt 401, { error: 'Invalid credentials' }.to_json unless user&.authenticate(data['password']) |
| 108 | + { token: issue_token({ user_id: user.id.to_s }) }.to_json |
| 109 | + end |
| 110 | +end |
| 111 | +EOF |
| 112 | + |
| 113 | +# 8) app/controllers/tasks_controller.rb |
| 114 | +cat > ruby/app/controllers/tasks_controller.rb << 'EOF' |
| 115 | +require 'sinatra/base' |
| 116 | +require 'json' |
| 117 | +require_relative '../models/task' |
| 118 | +require_relative '../models/user' |
| 119 | +require_relative '../../initializers/jwt' |
| 120 | +
|
| 121 | +class TasksController < Sinatra::Base |
| 122 | + before do |
| 123 | + pass if request.path_info =~ %r{^/tasks/public} |
| 124 | + auth_header = request.env['HTTP_AUTHORIZATION'] || '' |
| 125 | + token = auth_header.split(' ').last |
| 126 | + payload = decode_token(token) |
| 127 | + halt 401, { error: 'Unauthorized' }.to_json unless payload |
| 128 | + @current_user = User.find(payload['user_id']) rescue nil |
| 129 | + halt 401, { error: 'Unauthorized' }.to_json unless @current_user |
| 130 | + end |
| 131 | +
|
| 132 | + # public endpoint for stats |
| 133 | + get '/tasks/public/stats' do |
| 134 | + total = Task.count |
| 135 | + completed = Task.where(completed: true).count |
| 136 | + { total: total, completed: completed, incomplete: total - completed }.to_json |
| 137 | + end |
| 138 | +
|
| 139 | + # CRUD |
| 140 | + get '/tasks' do |
| 141 | + tasks = @current_user.tasks.order_by(:position.asc) |
| 142 | + tasks.to_json |
| 143 | + end |
| 144 | +
|
| 145 | + post '/tasks' do |
| 146 | + data = JSON.parse(request.body.read) |
| 147 | + task = @current_user.tasks.new(title: data['title'], position: data['position']) |
| 148 | + halt 400, { error: task.errors.full_messages }.to_json unless task.save |
| 149 | + status 201 |
| 150 | + task.to_json |
| 151 | + end |
| 152 | +
|
| 153 | + put '/tasks/:id' do |
| 154 | + task = @current_user.tasks.find(params['id']) |
| 155 | + data = JSON.parse(request.body.read) |
| 156 | + task.update(title: data['title'], completed: data['completed'], position: data['position']) |
| 157 | + task.to_json |
| 158 | + end |
| 159 | +
|
| 160 | + delete '/tasks/:id' do |
| 161 | + task = @current_user.tasks.find(params['id']) |
| 162 | + task.destroy |
| 163 | + status 204 |
| 164 | + end |
| 165 | +end |
| 166 | +EOF |
| 167 | + |
| 168 | +# 9) app.rb (main entry) |
| 169 | +cat > ruby/app.rb << 'EOF' |
| 170 | +require 'sinatra/base' |
| 171 | +require 'mongoid' |
| 172 | +require 'rack/cors' |
| 173 | +require_relative './config/mongoid' |
| 174 | +require_relative './initializers/jwt' |
| 175 | +require_relative 'app/controllers/auth_controller' |
| 176 | +require_relative 'app/controllers/tasks_controller' |
| 177 | +
|
| 178 | +Mongoid.load!('config/mongoid.yml', ENV['RACK_ENV'] || :development) |
| 179 | +
|
| 180 | +class TaskNexusApp < Sinatra::Base |
| 181 | + use Rack::Cors do |
| 182 | + allow do |
| 183 | + origins '*' |
| 184 | + resource '*', headers: :any, methods: [:get, :post, :put, :delete, :options] |
| 185 | + end |
| 186 | + end |
| 187 | +
|
| 188 | + use AuthController |
| 189 | + use TasksController |
| 190 | +
|
| 191 | + get '/' do |
| 192 | + 'TaskNexus API is running!' |
| 193 | + end |
| 194 | +end |
| 195 | +
|
| 196 | +run TaskNexusApp |
| 197 | +EOF |
| 198 | + |
| 199 | +# 10) config.ru for Rack |
| 200 | +cat > ruby/config.ru << 'EOF' |
| 201 | +require './app' |
| 202 | +run TaskNexusApp |
| 203 | +EOF |
| 204 | + |
| 205 | +# 11) Dockerfile |
| 206 | +cat > ruby/Dockerfile << 'EOF' |
| 207 | +FROM ruby:3.1 |
| 208 | +
|
| 209 | +WORKDIR /usr/src/app |
| 210 | +COPY Gemfile* ./ |
| 211 | +RUN bundle install |
| 212 | +
|
| 213 | +COPY . . |
| 214 | +EXPOSE 4567 |
| 215 | +CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"] |
| 216 | +EOF |
| 217 | + |
| 218 | +# 12) .env.example |
| 219 | +cat > ruby/.env.example << 'EOF' |
| 220 | +MONGODB_URI=mongodb://mongo:27017/tasknexus_prod |
| 221 | +JWT_SECRET=replace_with_strong_secret |
| 222 | +EOF |
| 223 | + |
| 224 | +echo "✅ Ruby backend scaffold complete under ruby/" |
| 225 | +echo "• To run locally without Docker:" |
| 226 | +echo " cd ruby" |
| 227 | +echo " export MONGODB_URI=mongodb://127.0.0.1:27017/tasknexus_dev" |
| 228 | +echo " bundle install" |
| 229 | +echo " bundle exec rackup" |
| 230 | +echo "• To run via Docker:" |
| 231 | +echo " cd ruby" |
| 232 | +echo " docker build -t hoangsonww/tasknexus-api ." |
| 233 | +echo " docker run -e MONGODB_URI=mongodb://host.docker.internal:27017/tasknexus_dev -e JWT_SECRET=you_choose --rm -p 4567:4567 hoangsonww/tasknexus-api" |
0 commit comments