Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
!/app/assets/builds/.keep
/public/assets

# Ignore the Expo app. It is shipped to the app stores by EAS, not baked into
# the Rails image, and its node_modules would bloat the build context.
/mobile/

# Ignore CI service files.
/.github

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@
# Ignore key files for decrypting credentials and more.
/config/*.key

# Ignore the alternate data stream files Windows attaches to downloaded files,
# which surface as real files when the repo lives on a WSL mount.
*:Zone.Identifier

6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }

AllCops:
Exclude:
# The Expo app. Its node_modules ships CocoaPods .podspec files, which are
# Ruby and would otherwise be linted as if we wrote them.
- "mobile/**/*"

# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

# Handle Cross-Origin Resource Sharing so the Expo app can call the API [https://github.com/cyu/rack-cors]
gem "rack-cors"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
Expand Down Expand Up @@ -401,6 +404,7 @@ DEPENDENCIES
kamal
propshaft
puma (>= 5.0)
rack-cors
rails (~> 8.1.3)
rubocop-rails-omakase
selenium-webdriver
Expand Down Expand Up @@ -504,6 +508,7 @@ CHECKSUMS
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-cors (3.0.0) sha256=7b95be61db39606906b61b83bd7203fa802b0ceaaad8fcb2fef39e097bf53f68
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
Expand Down
161 changes: 148 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,159 @@
# README
# Alongwithyou

This README would normally document whatever steps are necessary to get the
application up and running.
A Rails 8 JSON API with a React Native phone app built on Expo.

Things you may want to cover:
```
. Rails 8.1 API (Ruby 4.0, SQLite)
├─ app/ └─ app/controllers/api/v1 JSON endpoints
└─ mobile/ Expo SDK 57 app (React 19, React Native 0.86, TypeScript)
└─ src/lib/api.ts typed client for the API above
```

* Ruby version
The two halves are independent programs that talk over HTTP. Rails never renders
the phone UI, and the phone app is not served by Rails; it is bundled by Metro
in development and shipped to the app stores by EAS in production.

* System dependencies
## Requirements

* Configuration
| | |
|---|---|
| Ruby | 4.0.5 (see `.ruby-version`) |
| Node | **20.19.4 or newer** — React Native 0.86 refuses to build on older versions |

* Database creation
If `node -v` reports something older, install a current LTS with
[nvm](https://github.com/nvm-sh/nvm):

* Database initialization
```sh
nvm install --lts
nvm use --lts
```

* How to run the test suite
## Setup

* Services (job queues, cache servers, search engines, etc.)
```sh
bin/setup --skip-server # gems, database, seed data
(cd mobile && npm install) # phone app dependencies
```

* Deployment instructions
## Running it

* ...
You need both processes up. Use two terminals:

```sh
# Terminal 1 — the API. Bind to 0.0.0.0 so a phone can reach it.
bin/rails server -b 0.0.0.0

# Terminal 2 — the phone app
cd mobile && npx expo start
```

Then press `i` for the iOS simulator, `a` for the Android emulator, `w` for the
browser, or scan the QR code with Expo Go on a real phone. Open the **Tasks**
tab: the list is served by Rails, and adding, ticking and deleting write back to
it.

## How the app finds Rails

`localhost` means a different machine on every target, so hardcoding it breaks
something immediately. `mobile/src/lib/api.ts` instead reuses the address Metro
is already serving the JavaScript bundle from — if the phone can download JS
from `192.168.1.5:8081`, it can reach Rails at `192.168.1.5:3000`.

| Target | Resolved API URL |
|---|---|
| iOS simulator / web | `http://localhost:3000` |
| Android emulator | `http://10.0.2.2:3000` (the emulator's alias for the host) |
| Physical device | `http://<your LAN IP>:3000` |

To point somewhere else — a different port, a tunnel, staging, or any release
build, where there is no Metro server to ask — copy `mobile/.env.example` to
`mobile/.env` and set `EXPO_PUBLIC_API_URL`.

> `EXPO_PUBLIC_*` values are inlined into the JavaScript bundle at build time.
> They are readable by anyone with the app. Never put secrets there.

## Running on a physical phone

The phone and the computer must be on the same network, and the computer's
firewall must allow inbound connections on ports 3000 and 8081.

**On WSL2 or inside Docker**, this needs one extra step, because the Linux
environment has its own virtual network adapter that your phone cannot see. From
an **administrator PowerShell** on Windows, forward both ports to WSL:

```powershell
$wsl = (wsl hostname -I).Split()[0]
netsh interface portproxy add v4tov4 listenport=3000 listenaddress=0.0.0.0 connectport=3000 connectaddress=$wsl
netsh interface portproxy add v4tov4 listenport=8081 listenaddress=0.0.0.0 connectport=8081 connectaddress=$wsl
New-NetFirewallRule -DisplayName "Rails + Metro" -Direction Inbound -LocalPort 3000,8081 -Protocol TCP -Action Allow
```

Then set `EXPO_PUBLIC_API_URL` to `http://<your Windows LAN IP>:3000`, since the
WSL address Metro reports is not reachable from the phone. Re-run the portproxy
commands when the WSL IP changes, which it does on reboot.

Alternatively `npx expo start --tunnel` routes the *bundle* through the
internet, avoiding the firewall entirely — but it does not tunnel your API, so
you still need `EXPO_PUBLIC_API_URL` pointing at something the phone can reach.

## The API

All endpoints live under `/api/v1` and speak JSON. `Task` is a placeholder
resource wired end to end as a working example — replace it with your real
model.

| Method | Path | |
|---|---|---|
| `GET` | `/api/v1/tasks` | list, newest first |
| `POST` | `/api/v1/tasks` | create |
| `GET` | `/api/v1/tasks/:id` | show |
| `PATCH` | `/api/v1/tasks/:id` | update |
| `DELETE` | `/api/v1/tasks/:id` | destroy |

```sh
curl localhost:3000/api/v1/tasks
curl localhost:3000/api/v1/tasks -H 'Content-Type: application/json' -d '{"task":{"title":"Try it"}}'
```

Controllers inherit from `Api::BaseController`, which is an
`ActionController::API` — no cookies, no CSRF tokens, no browser version check,
none of which apply to a phone. It turns exceptions into predictable JSON:

```jsonc
// 404
{ "error": "not_found", "message": "Couldn't find Task with 'id'=99" }

// 422 — "message" for a banner, "errors" to mark up individual fields
{ "error": "unprocessable_entity",
"message": "Title can't be blank",
"errors": { "title": ["can't be blank"] } }
```

CORS is configured in `config/initializers/cors.rb`. It is wide open in
development and driven by the `CORS_ORIGINS` environment variable elsewhere. It
only affects the web target; native builds are not subject to CORS.

## Tests

```sh
bin/rails test # Rails
bin/ci # everything CI runs: rubocop, brakeman, audits, tests
cd mobile && npx tsc --noEmit # TypeScript
```

Rubocop, Brakeman and the Docker build context all skip `mobile/`, since its
`node_modules` ships Ruby CocoaPods scripts that would otherwise be linted and
scanned as if they were ours.

## Next steps

- **Authentication.** There is none yet. Every endpoint is public. A token
scheme (`has_secure_password` plus a bearer token, or `authenticate_by`) fits
a phone client better than cookie sessions; store the token with
`expo-secure-store`, not `AsyncStorage`.
- **Native builds.** `npx expo start` runs inside Expo Go, which only includes
Expo's own native modules. The moment you add a library with custom native
code you need a development build (`npx expo run:ios` / `run:android`) and
[EAS Build](https://docs.expo.dev/build/introduction/) for the app stores.
- **Production database.** SQLite is the default here and is genuinely fine for
a single server, but check `config/database.yml` before scaling out.
37 changes: 37 additions & 0 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module Api
# Base class for every JSON endpoint.
#
# Inherits from ActionController::API rather than ApplicationController: the
# Expo client has no cookies, no CSRF token and no browser, so the view/flash/
# CSRF layers that ApplicationController pulls in are dead weight here. It
# also means `allow_browser` does not run, which would otherwise reject
# requests coming from a phone.
class BaseController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request

private
def not_found(error)
render json: { error: "not_found", message: error.message }, status: :not_found
end

def bad_request(error)
render json: { error: "bad_request", message: error.message }, status: :bad_request
end

# Shape validation failures so the client can show a banner *and* mark up
# individual fields:
# {
# "error": "unprocessable_entity",
# "message": "Title can't be blank",
# "errors": { "title": ["can't be blank"] }
# }
def unprocessable(record)
render json: {
error: "unprocessable_entity",
message: record.errors.full_messages.to_sentence,
errors: record.errors.to_hash
}, status: :unprocessable_entity
end
end
end
47 changes: 47 additions & 0 deletions app/controllers/api/v1/tasks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Api
module V1
class TasksController < Api::BaseController
before_action :set_task, only: %i[ show update destroy ]

def index
render json: Task.newest_first
end

def show
render json: @task
end

def create
task = Task.new(task_params)

if task.save
render json: task, status: :created
else
unprocessable(task)
end
end

def update
if @task.update(task_params)
render json: @task
else
unprocessable(@task)
end
end

def destroy
@task.destroy
head :no_content
end

private
def set_task
@task = Task.find(params[:id])
end

def task_params
params.expect(task: [ :title, :completed ])
end
end
end
end
7 changes: 7 additions & 0 deletions app/models/task.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Example resource, wired end to end to the Expo app in mobile/.
# Rename or replace it once the real domain model takes shape.
class Task < ApplicationRecord
validates :title, presence: true, length: { maximum: 255 }

scope :newest_first, -> { order(created_at: :desc) }
end
7 changes: 7 additions & 0 deletions config/brakeman.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
# Brakeman scans every .rb file under the app root. The Expo project in mobile/
# vendors React Native, whose node_modules contain CocoaPods build scripts
# written in Ruby. Those are third-party build tooling, not part of this Rails
# app, and they generate a wall of false positives.
:skip_files:
- mobile/
6 changes: 6 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log

# Let the Expo app reach this server from a phone or emulator on the LAN.
# Rails already allows bare IPs in development, but mDNS names like
# "my-laptop.local" are rejected by Host Authorization with a 403. Add extra
# hostnames as a comma separated RAILS_DEVELOPMENT_HOSTS env var.
config.hosts << /.*\.local(:\d+)?\z/

# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load

Expand Down
27 changes: 27 additions & 0 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Be sure to restart your server when you modify this file.
#
# Cross-Origin Resource Sharing (CORS) for the Expo client.
#
# Note that CORS is a *browser* mechanism. Native iOS/Android builds are not
# subject to it, so this only really matters for `npx expo start --web`, which
# runs the app as React Native Web in a real browser. It is still worth getting
# right so the web target does not silently break.

Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
# In development the Expo web bundler serves from an arbitrary localhost
# port, so accept anything. In production, list the origins explicitly via
# CORS_ORIGINS (comma separated), e.g. "https://app.example.com".
if Rails.env.local?
origins "*"
else
origins ENV.fetch("CORS_ORIGINS", "").split(",").map(&:strip).reject(&:empty?)
end

resource "/api/*",
headers: :any,
methods: [ :get, :post, :patch, :put, :delete, :options, :head ],
expose: [ "Authorization" ],
max_age: 600
end
end
7 changes: 7 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# JSON API consumed by the Expo app in mobile/.
namespace :api do
namespace :v1 do
resources :tasks
end
end

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
Expand Down
Loading