gemstack 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { api, API_PATH } from "@/lib/gemstack/client";
|
|
6
|
+
import styles from "./page.module.css";
|
|
7
|
+
|
|
8
|
+
// The GemStack welcome page: shows that Next.js renders and that the browser
|
|
9
|
+
// reaches the Ruby API through the same origin. Replace it with your app —
|
|
10
|
+
// this file and app/page.module.css are yours to delete.
|
|
11
|
+
|
|
12
|
+
const DOCS = "https://github.com/gemstack-rb/gemstack/tree/main/docs";
|
|
13
|
+
|
|
14
|
+
export default function Welcome() {
|
|
15
|
+
const health = useQuery({
|
|
16
|
+
queryKey: ["gemstack", "health"],
|
|
17
|
+
queryFn: async () => {
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
const body = await api.get<{ status: string }>("/health");
|
|
20
|
+
return { ...body, ms: Math.round(performance.now() - started) };
|
|
21
|
+
},
|
|
22
|
+
retry: 1,
|
|
23
|
+
});
|
|
24
|
+
const apiState = health.isPending ? "pending" : health.isError ? "error" : "ok";
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={styles.shell}>
|
|
28
|
+
<main className={styles.main}>
|
|
29
|
+
<header className={styles.hero}>
|
|
30
|
+
<GemLogo className={styles.logo} />
|
|
31
|
+
<p className={styles.eyebrow}>GemStack</p>
|
|
32
|
+
<h1 className={styles.title}>
|
|
33
|
+
Yay! You're on <span className={styles.accent}>GemStack</span>.
|
|
34
|
+
</h1>
|
|
35
|
+
<p className={styles.lead}>
|
|
36
|
+
<strong><%= module_name %></strong> is running: a Next.js frontend and a Ruby API, served from one origin.
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<div className={styles.checks} role="status" aria-live="polite">
|
|
40
|
+
<span className={`${styles.check} ${styles.ok}`}>
|
|
41
|
+
<span className={styles.dot} aria-hidden="true" /> Next.js rendering
|
|
42
|
+
</span>
|
|
43
|
+
<span className={`${styles.check} ${styles[apiState]}`}>
|
|
44
|
+
<span className={styles.dot} aria-hidden="true" />
|
|
45
|
+
{apiState === "pending" && "Connecting to the Ruby API…"}
|
|
46
|
+
{apiState === "ok" && (
|
|
47
|
+
<>
|
|
48
|
+
Ruby API connected · <code>GET {API_PATH}/health</code> in {health.data?.ms} ms
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
{apiState === "error" && <>Ruby API unreachable: {health.error?.message}</>}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
</header>
|
|
55
|
+
|
|
56
|
+
<section className={styles.grid} aria-label="Next steps">
|
|
57
|
+
<Card
|
|
58
|
+
step="1"
|
|
59
|
+
title="Generate a resource"
|
|
60
|
+
text="Migration, model, API, typed TypeScript client and Next.js pages — in one command."
|
|
61
|
+
command="gemstack g resource Product name:string"
|
|
62
|
+
/>
|
|
63
|
+
<Card
|
|
64
|
+
step="2"
|
|
65
|
+
title="Explore your API"
|
|
66
|
+
text="Every endpoint with its types, and a form to try requests from the browser."
|
|
67
|
+
href={`${API_PATH}/docs`}
|
|
68
|
+
linkLabel={`Open ${API_PATH}/docs`}
|
|
69
|
+
/>
|
|
70
|
+
<Card
|
|
71
|
+
step="3"
|
|
72
|
+
title="Check your setup"
|
|
73
|
+
text="Ruby, Node.js, the database, migrations and the TypeScript contract — with the fix for anything off."
|
|
74
|
+
command="gemstack doctor"
|
|
75
|
+
/>
|
|
76
|
+
<Card
|
|
77
|
+
step="4"
|
|
78
|
+
title="Read the guides"
|
|
79
|
+
text="Models, controllers, jobs, realtime, authentication, storage and deployment."
|
|
80
|
+
href={DOCS}
|
|
81
|
+
linkLabel="Open the guides"
|
|
82
|
+
external
|
|
83
|
+
/>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<p className={styles.edit}>
|
|
87
|
+
Edit <code>frontend/app/page.tsx</code> to replace this page · API code lives in <code>app/</code>
|
|
88
|
+
</p>
|
|
89
|
+
</main>
|
|
90
|
+
|
|
91
|
+
<footer className={styles.footer}>
|
|
92
|
+
<span>
|
|
93
|
+
GemStack <%= version %> · Ruby <%= ruby_version %> · Next.js
|
|
94
|
+
</span>
|
|
95
|
+
<span>
|
|
96
|
+
Open source by{" "}
|
|
97
|
+
<a href="https://www.adwaretech.com" target="_blank" rel="noreferrer">
|
|
98
|
+
Adware Technologies
|
|
99
|
+
</a>
|
|
100
|
+
</span>
|
|
101
|
+
</footer>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Card(props: {
|
|
107
|
+
step: string;
|
|
108
|
+
title: string;
|
|
109
|
+
text: string;
|
|
110
|
+
command?: string;
|
|
111
|
+
href?: string;
|
|
112
|
+
linkLabel?: string;
|
|
113
|
+
external?: boolean;
|
|
114
|
+
}) {
|
|
115
|
+
return (
|
|
116
|
+
<article className={styles.card}>
|
|
117
|
+
<span className={styles.step} aria-hidden="true">
|
|
118
|
+
{props.step}
|
|
119
|
+
</span>
|
|
120
|
+
<h2 className={styles.cardTitle}>{props.title}</h2>
|
|
121
|
+
<p className={styles.cardText}>{props.text}</p>
|
|
122
|
+
{props.command && <Command text={props.command} />}
|
|
123
|
+
{props.href && (
|
|
124
|
+
<a
|
|
125
|
+
className={styles.link}
|
|
126
|
+
href={props.href}
|
|
127
|
+
{...(props.external ? { target: "_blank", rel: "noreferrer" } : {})}
|
|
128
|
+
>
|
|
129
|
+
{props.linkLabel} <span aria-hidden="true">→</span>
|
|
130
|
+
</a>
|
|
131
|
+
)}
|
|
132
|
+
</article>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function Command({ text }: { text: string }) {
|
|
137
|
+
const [copied, setCopied] = useState(false);
|
|
138
|
+
async function copy() {
|
|
139
|
+
try {
|
|
140
|
+
await navigator.clipboard.writeText(text);
|
|
141
|
+
setCopied(true);
|
|
142
|
+
setTimeout(() => setCopied(false), 1500);
|
|
143
|
+
} catch {
|
|
144
|
+
// Clipboard unavailable (e.g. not a secure context): the command stays selectable.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
<div className={styles.command}>
|
|
149
|
+
<code>
|
|
150
|
+
<span className={styles.prompt} aria-hidden="true">
|
|
151
|
+
$
|
|
152
|
+
</span>{" "}
|
|
153
|
+
{text}
|
|
154
|
+
</code>
|
|
155
|
+
<button type="button" className={styles.copy} onClick={copy} aria-label={`Copy: ${text}`}>
|
|
156
|
+
{copied ? "Copied" : "Copy"}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function GemLogo({ className }: { className?: string }) {
|
|
163
|
+
return (
|
|
164
|
+
<svg className={className} viewBox="0 0 64 64" role="img" aria-label="GemStack">
|
|
165
|
+
<defs>
|
|
166
|
+
<linearGradient id="gemstack-gem" x1="0" y1="0" x2="1" y2="1">
|
|
167
|
+
<stop offset="0" stopColor="#ff5f8f" />
|
|
168
|
+
<stop offset="0.55" stopColor="#e11d48" />
|
|
169
|
+
<stop offset="1" stopColor="#7c3aed" />
|
|
170
|
+
</linearGradient>
|
|
171
|
+
</defs>
|
|
172
|
+
<path d="M18 8h28l12 15-26 34L6 23z" fill="url(#gemstack-gem)" />
|
|
173
|
+
<path
|
|
174
|
+
d="M6 23h52M18 8l7 15 7-15 7 15 7-15M25 23l7 34 7-34"
|
|
175
|
+
fill="none"
|
|
176
|
+
stroke="rgba(255,255,255,0.55)"
|
|
177
|
+
strokeWidth="1.4"
|
|
178
|
+
strokeLinejoin="round"
|
|
179
|
+
/>
|
|
180
|
+
<path d="M18 8h28l-7 15H25z" fill="rgba(255,255,255,0.18)" />
|
|
181
|
+
</svg>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { ApiError } from "@/lib/gemstack/client";
|
|
6
|
+
|
|
7
|
+
// Server state lives in TanStack Query; local UI state is plain React state;
|
|
8
|
+
// URL state uses Next.js search params. No global store is needed by default.
|
|
9
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
10
|
+
const [queryClient] = useState(
|
|
11
|
+
() =>
|
|
12
|
+
new QueryClient({
|
|
13
|
+
defaultOptions: {
|
|
14
|
+
queries: {
|
|
15
|
+
staleTime: 30_000,
|
|
16
|
+
// Don't retry requests the API rejected (4xx); retry transient failures twice.
|
|
17
|
+
retry: (failureCount, error) => !(error instanceof ApiError && error.isClientError) && failureCount < 2,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GemStack API client runtime.
|
|
3
|
+
*
|
|
4
|
+
* This file belongs to your application (it is not a hidden dependency):
|
|
5
|
+
* add auth headers, interceptors or logging here as needed.
|
|
6
|
+
*
|
|
7
|
+
* Where requests go:
|
|
8
|
+
* - Browser: relative URLs ("/api/..."), i.e. the same origin — no CORS, no config.
|
|
9
|
+
* - Server Components / Route Handlers: GEMSTACK_API_URL (injected by `gemstack dev`,
|
|
10
|
+
* set by you in production) so the Next.js server calls Ruby directly.
|
|
11
|
+
* - API on another domain: set NEXT_PUBLIC_GEMSTACK_API_URL.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const API_PATH = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
|
|
15
|
+
|
|
16
|
+
/** The JSON error envelope every GemStack API error uses. */
|
|
17
|
+
export interface ApiErrorBody {
|
|
18
|
+
error: { code: string; message: string; request_id?: string };
|
|
19
|
+
/** Field-level validation messages, e.g. { name: ["is required"] }. */
|
|
20
|
+
errors?: Record<string, string[]>;
|
|
21
|
+
/** Development only (config.http.show_exceptions): the server-side exception. */
|
|
22
|
+
exception?: { class: string; message: string; backtrace: string[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApiError extends Error {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly requestId?: string;
|
|
29
|
+
readonly errors: Record<string, string[]>;
|
|
30
|
+
|
|
31
|
+
constructor(status: number, body: ApiErrorBody | null, fallbackMessage: string) {
|
|
32
|
+
super(body?.error?.message ?? fallbackMessage);
|
|
33
|
+
this.name = "ApiError";
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.code = body?.error?.code ?? "http_error";
|
|
36
|
+
this.requestId = body?.error?.request_id;
|
|
37
|
+
this.errors = body?.errors ?? {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True for 4xx responses: retrying the same request won't help. */
|
|
41
|
+
get isClientError(): boolean {
|
|
42
|
+
return this.status >= 400 && this.status < 500;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type QueryValue = string | number | boolean | null | undefined;
|
|
47
|
+
export type Query = Record<string, QueryValue | QueryValue[]>;
|
|
48
|
+
|
|
49
|
+
export interface RequestOptions extends Omit<RequestInit, "body" | "method"> {
|
|
50
|
+
query?: Query;
|
|
51
|
+
body?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function baseUrl(): string {
|
|
55
|
+
const publicUrl = process.env.NEXT_PUBLIC_GEMSTACK_API_URL;
|
|
56
|
+
if (publicUrl) return publicUrl.replace(/\/$/, "");
|
|
57
|
+
if (typeof window === "undefined") {
|
|
58
|
+
return (process.env.GEMSTACK_API_URL ?? "http://127.0.0.1:4000").replace(/\/$/, "");
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Builds a URL for an API path: apiUrl("/products", { page: 2 }) → "/api/products?page=2". */
|
|
64
|
+
export function apiUrl(path: string, query?: Query): string {
|
|
65
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
66
|
+
let url = `${baseUrl()}${API_PATH}${normalized}`;
|
|
67
|
+
if (query) {
|
|
68
|
+
const params = new URLSearchParams();
|
|
69
|
+
for (const [key, value] of Object.entries(query)) {
|
|
70
|
+
const values = Array.isArray(value) ? value : [value];
|
|
71
|
+
for (const item of values) {
|
|
72
|
+
if (item !== undefined && item !== null) params.append(Array.isArray(value) ? `${key}[]` : key, String(item));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const qs = params.toString();
|
|
76
|
+
if (qs) url += `?${qs}`;
|
|
77
|
+
}
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse(text: string): unknown {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
|
90
|
+
const { query, body, headers, ...init } = options;
|
|
91
|
+
const requestHeaders = new Headers(headers);
|
|
92
|
+
requestHeaders.set("accept", "application/json");
|
|
93
|
+
|
|
94
|
+
let payload: BodyInit | undefined;
|
|
95
|
+
if (body !== undefined) {
|
|
96
|
+
if (body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams) {
|
|
97
|
+
payload = body;
|
|
98
|
+
} else {
|
|
99
|
+
payload = JSON.stringify(body);
|
|
100
|
+
requestHeaders.set("content-type", "application/json");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const response = await fetch(apiUrl(path, query), { ...init, method, headers: requestHeaders, body: payload });
|
|
105
|
+
const text = response.status === 204 ? "" : await response.text();
|
|
106
|
+
const data = text ? parse(text) : null;
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const exception = (data as ApiErrorBody | null)?.exception;
|
|
110
|
+
if (exception && process.env.NODE_ENV !== "production") {
|
|
111
|
+
// The Ruby exception behind a 500, so it shows up next to the failing request.
|
|
112
|
+
console.error(`[GemStack API] ${method} ${path} → ${exception.class}: ${exception.message}\n ${exception.backtrace.slice(0, 8).join("\n ")}`);
|
|
113
|
+
}
|
|
114
|
+
throw new ApiError(response.status, data as ApiErrorBody | null, response.statusText || `HTTP ${response.status}`);
|
|
115
|
+
}
|
|
116
|
+
return data as T;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const api = {
|
|
120
|
+
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, options),
|
|
121
|
+
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, { ...options, body }),
|
|
122
|
+
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, { ...options, body }),
|
|
123
|
+
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PATCH", path, { ...options, body }),
|
|
124
|
+
delete: <T = void>(path: string, options?: RequestOptions) => request<T>("DELETE", path, options),
|
|
125
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
// One public origin, in every environment:
|
|
4
|
+
// - `gemstack dev`: the GemStack gateway routes /api/* to Ruby before requests reach Next.js.
|
|
5
|
+
// - Production: set GEMSTACK_API_URL (e.g. http://api.internal:4000) and Next.js proxies
|
|
6
|
+
// /api/* to the Ruby API. Leave it unset if a reverse proxy already routes /api/*.
|
|
7
|
+
const apiUrl = process.env.GEMSTACK_API_URL?.replace(/\/$/, "");
|
|
8
|
+
const apiPath = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
|
|
9
|
+
|
|
10
|
+
const nextConfig: NextConfig = {
|
|
11
|
+
async rewrites() {
|
|
12
|
+
if (!apiUrl) return [];
|
|
13
|
+
return [{ source: `${apiPath}/:path*`, destination: `${apiUrl}${apiPath}/:path*` }];
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default nextConfig;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= name %>-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tanstack/react-query": "^5.104.0",
|
|
13
|
+
"next": "^16.3.6",
|
|
14
|
+
"react": "^19.3.0",
|
|
15
|
+
"react-dom": "^19.3.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/react": "^19.3.0",
|
|
20
|
+
"@types/react-dom": "^19.3.0",
|
|
21
|
+
"typescript": "~5.9.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enqueue with <%= class_name %>.perform_later(...) — arguments must be JSON values (pass ids, not records).
|
|
4
|
+
# Runs in `gemstack jobs` (and in `gemstack dev`). Delivery is at-least-once: keep perform idempotent.
|
|
5
|
+
class <%= class_name %> < ApplicationJob
|
|
6
|
+
<% if queue_name -%>
|
|
7
|
+
queue :<%= queue_name %>
|
|
8
|
+
<% end -%>
|
|
9
|
+
# retry_on SomeTimeoutError, attempts: 5 # default: any StandardError, 10 attempts, exponential backoff
|
|
10
|
+
# discard_on GemStack::NotFound # drop instead of retrying
|
|
11
|
+
|
|
12
|
+
def perform(*args)
|
|
13
|
+
GemStack.logger.info("<%= class_name %> ran", args: args)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>Test < GemStack::TestCase
|
|
6
|
+
def test_enqueue
|
|
7
|
+
<%= class_name %>.perform_later(1)
|
|
8
|
+
|
|
9
|
+
assert_enqueued <%= class_name %>, args: [1]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_perform
|
|
13
|
+
perform_enqueued_jobs { <%= class_name %>.perform_later(1) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sequel migration — see https://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html
|
|
4
|
+
Sequel.migration do
|
|
5
|
+
change do
|
|
6
|
+
<% if table && !columns.empty? -%>
|
|
7
|
+
alter_table(:<%= table %>) do
|
|
8
|
+
<% columns.each do |column| -%>
|
|
9
|
+
<%= column %>
|
|
10
|
+
<% end -%>
|
|
11
|
+
end
|
|
12
|
+
<% else -%>
|
|
13
|
+
# create_table(:things) { primary_key :id, type: :Bignum }
|
|
14
|
+
# alter_table(:things) { add_column :name, String }
|
|
15
|
+
<% end -%>
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Who may do what with <%= model_name %> records (docs/authorization.md).
|
|
4
|
+
# `user` is the signed-in user (nil when anonymous); `record` the <%= model_name %>.
|
|
5
|
+
# In controllers: authorize!(record) checks "<action>?", policy_scope(<%= model_name %>) filters lists.
|
|
6
|
+
class <%= class_name %> < <%= parent_class %>
|
|
7
|
+
def index? = !user.nil?
|
|
8
|
+
def show? = owner?
|
|
9
|
+
def create? = !user.nil?
|
|
10
|
+
def update? = owner?
|
|
11
|
+
def destroy? = owner?
|
|
12
|
+
|
|
13
|
+
class Scope < Scope
|
|
14
|
+
# The <%= model_name %> records the user may list. Adjust to your data model.
|
|
15
|
+
def resolve = user ? scope.where(user_id: user.id) : scope.where(false)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def owner? = !user.nil? && record.respond_to?(:user_id) && record.user_id == user.id
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Realtime channels browsers may subscribe to (docs/realtime.md). Anything not
|
|
4
|
+
# listed here is refused. `*` matches one segment and is passed to the block,
|
|
5
|
+
# together with the request (cookies, headers) for authorization.
|
|
6
|
+
#
|
|
7
|
+
# GemStack.broadcast("orders:#{order.id}", "order.updated", order)
|
|
8
|
+
#
|
|
9
|
+
GemStack.channels do
|
|
10
|
+
# channel "announcements" # public
|
|
11
|
+
# channel "orders:*" do |order_id, request| # private
|
|
12
|
+
# Order.find_by(id: order_id)&.user_id == current_user_id(request)
|
|
13
|
+
# end
|
|
14
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GemStack realtime client (yours to edit — see docs/realtime.md).
|
|
3
|
+
*
|
|
4
|
+
* const off = realtime.subscribe("orders:42", (event) => console.log(event.event, event.data));
|
|
5
|
+
* off(); // unsubscribe
|
|
6
|
+
*
|
|
7
|
+
* useRealtime("products", (event) => queryClient.invalidateQueries({ queryKey: ["products"] }));
|
|
8
|
+
*
|
|
9
|
+
* One EventSource per tab carries every channel; changes to the set of
|
|
10
|
+
* channels made in the same tick cause a single reconnect. After a dropped
|
|
11
|
+
* connection the browser reconnects with Last-Event-ID and the server replays
|
|
12
|
+
* what was missed — or sends `gemstack.gap` (delivered to every handler) when
|
|
13
|
+
* it can't, so you can refetch. A refused channel's handlers get `gemstack.denied`
|
|
14
|
+
* while the other channels keep working.
|
|
15
|
+
*/
|
|
16
|
+
"use client";
|
|
17
|
+
|
|
18
|
+
import { useEffect, useRef } from "react";
|
|
19
|
+
import { apiUrl } from "./client";
|
|
20
|
+
|
|
21
|
+
export type RealtimeEvent<T = unknown> = {
|
|
22
|
+
id: string | null;
|
|
23
|
+
channel: string | null;
|
|
24
|
+
event: string;
|
|
25
|
+
data: T;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RealtimeHandler<T = unknown> = (event: RealtimeEvent<T>) => void;
|
|
29
|
+
export type RealtimeStatus = "idle" | "connecting" | "open" | "reconnecting";
|
|
30
|
+
|
|
31
|
+
export const GAP_EVENT = "gemstack.gap";
|
|
32
|
+
/** Delivered to a channel's handlers when the server refuses the subscription. */
|
|
33
|
+
export const DENIED_EVENT = "gemstack.denied";
|
|
34
|
+
|
|
35
|
+
class RealtimeClient {
|
|
36
|
+
private handlers = new Map<string, Set<RealtimeHandler>>();
|
|
37
|
+
private source: EventSource | null = null;
|
|
38
|
+
private connectedKey = "";
|
|
39
|
+
private lastEventId: string | null = null;
|
|
40
|
+
private scheduled = false;
|
|
41
|
+
private statusListeners = new Set<(status: RealtimeStatus) => void>();
|
|
42
|
+
status: RealtimeStatus = "idle";
|
|
43
|
+
|
|
44
|
+
subscribe<T = unknown>(channel: string, handler: RealtimeHandler<T>): () => void {
|
|
45
|
+
const set = this.handlers.get(channel) ?? new Set<RealtimeHandler>();
|
|
46
|
+
set.add(handler as RealtimeHandler);
|
|
47
|
+
this.handlers.set(channel, set);
|
|
48
|
+
this.schedule();
|
|
49
|
+
return () => {
|
|
50
|
+
set.delete(handler as RealtimeHandler);
|
|
51
|
+
if (set.size === 0) this.handlers.delete(channel);
|
|
52
|
+
this.schedule();
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onStatus(listener: (status: RealtimeStatus) => void): () => void {
|
|
57
|
+
this.statusListeners.add(listener);
|
|
58
|
+
return () => this.statusListeners.delete(listener);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private setStatus(status: RealtimeStatus) {
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.statusListeners.forEach((listener) => listener(status));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private schedule() {
|
|
67
|
+
if (this.scheduled) return;
|
|
68
|
+
this.scheduled = true;
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
this.scheduled = false;
|
|
71
|
+
this.connect();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private connect() {
|
|
76
|
+
if (typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
77
|
+
const channels = [...this.handlers.keys()].sort();
|
|
78
|
+
const key = channels.join(",");
|
|
79
|
+
if (key === this.connectedKey && this.source) return;
|
|
80
|
+
|
|
81
|
+
this.source?.close();
|
|
82
|
+
this.source = null;
|
|
83
|
+
this.connectedKey = key;
|
|
84
|
+
if (channels.length === 0) return this.setStatus("idle");
|
|
85
|
+
|
|
86
|
+
const query: Record<string, string> = { channels: key };
|
|
87
|
+
if (this.lastEventId) query.last_event_id = this.lastEventId;
|
|
88
|
+
const source = new EventSource(apiUrl("/realtime", query), { withCredentials: true });
|
|
89
|
+
this.source = source;
|
|
90
|
+
this.setStatus("connecting");
|
|
91
|
+
source.onopen = () => this.setStatus("open");
|
|
92
|
+
source.onerror = () => this.setStatus("reconnecting"); // the browser retries on its own
|
|
93
|
+
source.onmessage = (message) => {
|
|
94
|
+
if (message.lastEventId) this.lastEventId = message.lastEventId;
|
|
95
|
+
let event: RealtimeEvent;
|
|
96
|
+
try {
|
|
97
|
+
event = JSON.parse(message.data) as RealtimeEvent;
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const targets =
|
|
102
|
+
event.event === GAP_EVENT ? [...this.handlers.values()] : [this.handlers.get(event.channel ?? "") ?? new Set()];
|
|
103
|
+
targets.forEach((set) => set.forEach((handler) => handler(event)));
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const realtime = new RealtimeClient();
|
|
109
|
+
|
|
110
|
+
/** Subscribes while the component is mounted. Pass null to skip. */
|
|
111
|
+
export function useRealtime<T = unknown>(channel: string | null, handler: RealtimeHandler<T>) {
|
|
112
|
+
const latest = useRef(handler);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
latest.current = handler; // always call the newest handler without resubscribing
|
|
115
|
+
});
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!channel) return;
|
|
118
|
+
return realtime.subscribe<T>(channel, (event) => latest.current(event));
|
|
119
|
+
}, [channel]);
|
|
120
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= spec.plural_class %>Controller < ApplicationController
|
|
4
|
+
<% if spec.action?(:index) -%>
|
|
5
|
+
returns :index, GemStack::Page[<%= class_name %>Serializer]
|
|
6
|
+
<% end -%>
|
|
7
|
+
<% if spec.action?(:create) -%>
|
|
8
|
+
accepts :create, with: <%= class_name %>.input_schema
|
|
9
|
+
<% end -%>
|
|
10
|
+
<% if spec.action?(:update) -%>
|
|
11
|
+
accepts :update, with: <%= class_name %>.input_schema, partial: true
|
|
12
|
+
<% end -%>
|
|
13
|
+
<% if spec.action?(:index) -%>
|
|
14
|
+
|
|
15
|
+
# GET /api/<%= spec.url_segment %>?page=1&per_page=25
|
|
16
|
+
def index
|
|
17
|
+
render paginate(<%= class_name %>.order(:id))
|
|
18
|
+
end
|
|
19
|
+
<% end -%>
|
|
20
|
+
<% if spec.action?(:show) -%>
|
|
21
|
+
|
|
22
|
+
# GET /api/<%= spec.url_segment %>/:id
|
|
23
|
+
def show
|
|
24
|
+
render <%= class_name %>.find(params[:id])
|
|
25
|
+
end
|
|
26
|
+
<% end -%>
|
|
27
|
+
<% if spec.action?(:create) -%>
|
|
28
|
+
|
|
29
|
+
# POST /api/<%= spec.url_segment %>
|
|
30
|
+
def create
|
|
31
|
+
render <%= class_name %>.create(input), status: :created
|
|
32
|
+
end
|
|
33
|
+
<% end -%>
|
|
34
|
+
<% if spec.action?(:update) -%>
|
|
35
|
+
|
|
36
|
+
# PATCH /api/<%= spec.url_segment %>/:id
|
|
37
|
+
def update
|
|
38
|
+
<%= spec.variable %> = <%= class_name %>.find(params[:id])
|
|
39
|
+
<%= spec.variable %>.update(input)
|
|
40
|
+
render <%= spec.variable %>
|
|
41
|
+
end
|
|
42
|
+
<% end -%>
|
|
43
|
+
<% if spec.action?(:destroy) -%>
|
|
44
|
+
|
|
45
|
+
# DELETE /api/<%= spec.url_segment %>/:id
|
|
46
|
+
def destroy
|
|
47
|
+
<%= class_name %>.find(params[:id]).destroy
|
|
48
|
+
head :no_content
|
|
49
|
+
end
|
|
50
|
+
<% end -%>
|
|
51
|
+
end
|