parse-stack-next 5.3.0 → 5.4.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/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +15 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Live Query Listener for parse-stack-next
|
|
5
|
+
#
|
|
6
|
+
# An interactive console listener — like a tail / `rails console` that just
|
|
7
|
+
# prints: it logs in as a user, opens a LiveQuery subscription scoped to that
|
|
8
|
+
# user's session token, and prints every event (create / update / delete /
|
|
9
|
+
# enter / leave) until you press Ctrl-C.
|
|
10
|
+
#
|
|
11
|
+
# Because the subscription carries the user's `sessionToken`, Parse Server
|
|
12
|
+
# enforces ACLs on the live stream too: you only receive events for objects
|
|
13
|
+
# that user is allowed to read — "whatever the user can hear." Swap in a
|
|
14
|
+
# master-key subscription (use_master_key: true) to hear everything.
|
|
15
|
+
#
|
|
16
|
+
# Prerequisite: the `Post` class must exist on the server (run
|
|
17
|
+
# examples/basic_server.rb first, which provisions it). You also need a Parse
|
|
18
|
+
# Server with the LiveQuery websocket server enabled for the Post class.
|
|
19
|
+
#
|
|
20
|
+
# Run it, then in another terminal create/update/destroy Posts (e.g. via
|
|
21
|
+
# examples/basic_client.rb or the dashboard) and watch them stream in:
|
|
22
|
+
# export PARSE_SERVER_URL=http://localhost:1337/parse
|
|
23
|
+
# export PARSE_APP_ID=... PARSE_REST_KEY=...
|
|
24
|
+
# export PARSE_LIVE_QUERY_URL=ws://localhost:1337/parse # ws:// or wss://
|
|
25
|
+
# ruby examples/live_query_listener.rb
|
|
26
|
+
|
|
27
|
+
require "parse-stack-next"
|
|
28
|
+
require "parse/live_query"
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# 1. Configure the REST client + the LiveQuery websocket client
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
Parse.setup(
|
|
34
|
+
server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
|
|
35
|
+
app_id: ENV.fetch("PARSE_APP_ID"),
|
|
36
|
+
api_key: ENV.fetch("PARSE_REST_KEY"),
|
|
37
|
+
master_key: nil, # a plain client — the session token does the scoping
|
|
38
|
+
logging: false,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Parse.live_query_enabled = true
|
|
42
|
+
Parse::LiveQuery.configure do |config|
|
|
43
|
+
config.url = ENV.fetch("PARSE_LIVE_QUERY_URL", "ws://localhost:1337/parse")
|
|
44
|
+
config.application_id = ENV.fetch("PARSE_APP_ID")
|
|
45
|
+
config.client_key = ENV.fetch("PARSE_REST_KEY")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Post < Parse::Object
|
|
49
|
+
property :title, :string
|
|
50
|
+
property :body, :string
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# 2. Authenticate (so the subscription is ACL-scoped to this user)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
USERNAME = ENV.fetch("PARSE_USERNAME", "ada")
|
|
57
|
+
PASSWORD = ENV.fetch("PARSE_PASSWORD", "p4ssw0rd!")
|
|
58
|
+
|
|
59
|
+
user = Parse::User.login(USERNAME, PASSWORD) ||
|
|
60
|
+
Parse::User.signup(USERNAME, PASSWORD, "ada@example.com")
|
|
61
|
+
puts "Listening as #{user.username} (#{user.id}) — only Posts this user can read.\n\n"
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# 3. Open the subscription + register handlers
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
def stamp = Time.now.strftime("%H:%M:%S")
|
|
67
|
+
|
|
68
|
+
# `where:` narrows the live query (omit it to hear every readable Post);
|
|
69
|
+
# `session_token:` is what makes Parse Server apply this user's ACL to the
|
|
70
|
+
# stream. Use `use_master_key: true` instead to listen to everything.
|
|
71
|
+
subscription = Post.subscribe(
|
|
72
|
+
where: {}, # e.g. { :title.exists => true }
|
|
73
|
+
session_token: user.session_token,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
subscription.on(:subscribe) { puts "[#{stamp}] subscribed — waiting for events…" }
|
|
77
|
+
subscription.on(:create) { |post| puts "[#{stamp}] CREATE #{post.id} #{post.title.inspect}" }
|
|
78
|
+
subscription.on(:update) { |post, _orig| puts "[#{stamp}] UPDATE #{post.id} #{post.title.inspect}" }
|
|
79
|
+
subscription.on(:delete) { |post| puts "[#{stamp}] DELETE #{post.id}" }
|
|
80
|
+
subscription.on(:enter) { |post, _orig| puts "[#{stamp}] ENTER #{post.id} (now matches query)" }
|
|
81
|
+
subscription.on(:leave) { |post, _orig| puts "[#{stamp}] LEAVE #{post.id} (no longer matches)" }
|
|
82
|
+
subscription.on(:error) { |err| warn "[#{stamp}] ERROR #{err}" }
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# 4. Block and print until Ctrl-C
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
running = true
|
|
88
|
+
trap("INT") do
|
|
89
|
+
running = false # keep the handler tiny — just flip the flag
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "Press Ctrl-C to stop.\n\n"
|
|
93
|
+
sleep 0.2 while running # events arrive on the websocket thread
|
|
94
|
+
|
|
95
|
+
puts "\nStopping…"
|
|
96
|
+
subscription.unsubscribe
|
|
97
|
+
Parse::LiveQuery.reset! # closes the websocket connection
|
|
98
|
+
puts "Done."
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# RAG Chatbot Example for parse-stack-next
|
|
5
|
+
#
|
|
6
|
+
# A retrieval-augmented-generation (RAG) chatbot in a single file:
|
|
7
|
+
#
|
|
8
|
+
# 1. Store documents in Parse; the SDK manages their embeddings on save.
|
|
9
|
+
# 2. Retrieve the most relevant passages for a question with the
|
|
10
|
+
# `semantic_search` agent tool.
|
|
11
|
+
# 3. Hand those passages to an LLM to write the answer.
|
|
12
|
+
#
|
|
13
|
+
# The SDK owns the **R** (retrieval) and the embedding lifecycle. The **G**
|
|
14
|
+
# (generation) is a thin add-in over the OpenAI or Anthropic HTTP API, shown
|
|
15
|
+
# here with zero extra gems (`net/http` only).
|
|
16
|
+
#
|
|
17
|
+
# Retrieval (`Parse::Retrieval` / `semantic_search`) shipped in v5.2; managed
|
|
18
|
+
# `embed` and the embeddings provider registry shipped in v5.1. Vector search
|
|
19
|
+
# runs against MongoDB Atlas (`$vectorSearch`), so point the SDK at an
|
|
20
|
+
# Atlas-backed Parse Server / Mongo URI.
|
|
21
|
+
#
|
|
22
|
+
# Run it:
|
|
23
|
+
# export OPENAI_API_KEY=sk-... # embeddings + (optionally) generation
|
|
24
|
+
# export ANTHROPIC_API_KEY=sk-ant-... # if using the Anthropic backend
|
|
25
|
+
# export PARSE_SERVER_URL=http://localhost:1337/parse
|
|
26
|
+
# export PARSE_APP_ID=... PARSE_REST_KEY=... PARSE_MASTER_KEY=...
|
|
27
|
+
# ruby examples/rag_chatbot.rb
|
|
28
|
+
|
|
29
|
+
require "parse-stack-next"
|
|
30
|
+
require "net/http"
|
|
31
|
+
require "json"
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# 1. Embedding provider + Parse connection
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
# text-embedding-3-small is 1536-dim. Register it under :openai so the model's
|
|
38
|
+
# :vector property can resolve it by name at save / query time.
|
|
39
|
+
Parse::Embeddings.register(
|
|
40
|
+
:openai,
|
|
41
|
+
Parse::Embeddings::OpenAI.new(api_key: ENV.fetch("OPENAI_API_KEY")),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
Parse.setup(
|
|
45
|
+
server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
|
|
46
|
+
app_id: ENV.fetch("PARSE_APP_ID"),
|
|
47
|
+
api_key: ENV.fetch("PARSE_REST_KEY"),
|
|
48
|
+
master_key: ENV.fetch("PARSE_MASTER_KEY"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# 2. The model
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
#
|
|
55
|
+
# `embed` declares a MANAGED embedding: list the source fields, name the
|
|
56
|
+
# :vector property they feed, and the SDK recomputes the vector on `save`
|
|
57
|
+
# whenever those fields change (digest-tracked, so a no-op save makes zero
|
|
58
|
+
# provider calls). You never assign `embedding` yourself — it is
|
|
59
|
+
# write-protected. Set title/body and save.
|
|
60
|
+
#
|
|
61
|
+
# `agent_searchable` opts the class into the `semantic_search` agent tool and
|
|
62
|
+
# declares which fields an agent may filter on.
|
|
63
|
+
class KnowledgeArticle < Parse::Object
|
|
64
|
+
property :title, :string
|
|
65
|
+
property :body, :string
|
|
66
|
+
property :category, :string
|
|
67
|
+
|
|
68
|
+
property :embedding, :vector, dimensions: 1536, provider: :openai
|
|
69
|
+
|
|
70
|
+
# title + body feed :embedding, recomputed on save.
|
|
71
|
+
embed :title, :body, into: :embedding
|
|
72
|
+
|
|
73
|
+
# Opt into semantic_search; allow filtering on :category.
|
|
74
|
+
agent_searchable field: :embedding, filter_fields: %i[category]
|
|
75
|
+
|
|
76
|
+
# Declare the Atlas $vectorSearch index that retrieval needs.
|
|
77
|
+
mongo_search_index "knowledge_embedding",
|
|
78
|
+
{ fields: [{ type: "vector", path: "embedding",
|
|
79
|
+
numDimensions: 1536, similarity: "cosine" }] },
|
|
80
|
+
type: "vectorSearch"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# 3. The LLM generation add-in (NOT part of the SDK)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
#
|
|
87
|
+
# ~15 lines of HTTP per backend. Both take the retrieved chunks as context and
|
|
88
|
+
# return an answer grounded in them.
|
|
89
|
+
module ChatAnswerer
|
|
90
|
+
PROMPT = <<~SYS
|
|
91
|
+
You are a support assistant. Answer ONLY from the context below.
|
|
92
|
+
If the context does not contain the answer, say you don't know.
|
|
93
|
+
SYS
|
|
94
|
+
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
def context(chunks)
|
|
98
|
+
chunks.map { |c| "- #{c[:content]}" }.join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- OpenAI backend ---
|
|
102
|
+
def openai(question, chunks, model: "gpt-4o-mini")
|
|
103
|
+
post("https://api.openai.com/v1/chat/completions",
|
|
104
|
+
{ "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
|
|
105
|
+
{ model: model,
|
|
106
|
+
messages: [
|
|
107
|
+
{ role: "system", content: PROMPT },
|
|
108
|
+
{ role: "user",
|
|
109
|
+
content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
|
|
110
|
+
] })
|
|
111
|
+
.dig("choices", 0, "message", "content")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- Anthropic backend ---
|
|
115
|
+
def anthropic(question, chunks, model: "claude-opus-4-8")
|
|
116
|
+
post("https://api.anthropic.com/v1/messages",
|
|
117
|
+
{ "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"),
|
|
118
|
+
"anthropic-version" => "2023-06-01" },
|
|
119
|
+
{ model: model, max_tokens: 1024, system: PROMPT,
|
|
120
|
+
messages: [
|
|
121
|
+
{ role: "user",
|
|
122
|
+
content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
|
|
123
|
+
] })
|
|
124
|
+
.dig("content", 0, "text")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def post(url, headers, body)
|
|
128
|
+
uri = URI(url)
|
|
129
|
+
req = Net::HTTP::Post.new(uri, { "Content-Type" => "application/json" }.merge(headers))
|
|
130
|
+
req.body = JSON.generate(body)
|
|
131
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
|
|
132
|
+
JSON.parse(res.body)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# 4. Retrieval helper
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
#
|
|
140
|
+
# An unscoped Parse::Agent (no session_token: / acl_user: / acl_role:) runs in
|
|
141
|
+
# master posture using the master key already on the default client from
|
|
142
|
+
# Parse.setup — there is NO `master_key:` constructor argument.
|
|
143
|
+
#
|
|
144
|
+
# To scope retrieval to a signed-in user (so the bot only ever sees documents
|
|
145
|
+
# that user may read), construct it as:
|
|
146
|
+
# Parse::Agent.new(session_token: user.session_token)
|
|
147
|
+
#
|
|
148
|
+
# `execute(:semantic_search, ...)` returns
|
|
149
|
+
# { success:, data: { chunks:, documents:, count: } }.
|
|
150
|
+
def retrieve(agent, question, k: 4)
|
|
151
|
+
result = agent.execute(:semantic_search,
|
|
152
|
+
class_name: "KnowledgeArticle",
|
|
153
|
+
query: question,
|
|
154
|
+
k: k)
|
|
155
|
+
raise result[:error].to_s unless result[:success]
|
|
156
|
+
|
|
157
|
+
# Each chunk: { id:, score:, content:, metadata: { object_id:, ... } }.
|
|
158
|
+
# The parent record lives once in data[:documents], keyed by objectId.
|
|
159
|
+
result[:data][:chunks]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# 5. Seed a corpus + chat loop (runs when executed directly)
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
CORPUS = [
|
|
167
|
+
{ title: "Resetting your password",
|
|
168
|
+
body: "Open Settings, choose Security, then Reset Password. A link is emailed to you.",
|
|
169
|
+
category: "account" },
|
|
170
|
+
{ title: "Exporting your data",
|
|
171
|
+
body: "Use Settings > Export to download a ZIP of all your documents as JSON.",
|
|
172
|
+
category: "data" },
|
|
173
|
+
{ title: "Billing cycles",
|
|
174
|
+
body: "Plans renew monthly on the date you subscribed. Cancel anytime before renewal.",
|
|
175
|
+
category: "billing" },
|
|
176
|
+
].freeze
|
|
177
|
+
|
|
178
|
+
# Bulk back-fill / precompute outside the managed-save path: call the provider
|
|
179
|
+
# directly. `embed_text_batched` splits into the provider's recommended batch
|
|
180
|
+
# size (OpenAI: 100) and returns one vector per string, in order. (Not used by
|
|
181
|
+
# the demo below — `save` handles embedding — but shown for completeness.)
|
|
182
|
+
def precompute_vectors(texts)
|
|
183
|
+
provider = Parse::Embeddings.provider(:openai)
|
|
184
|
+
provider.embed_text_batched(texts, input_type: :search_document)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def seed_corpus!
|
|
188
|
+
# $vectorSearch needs an Atlas vector index on `embedding`, or retrieval
|
|
189
|
+
# raises IndexNotResolved. The model declares it; apply it once. This uses
|
|
190
|
+
# the mongo-direct writer (requires Parse::MongoDB configured against Atlas)
|
|
191
|
+
# — or create the same index in the Atlas UI.
|
|
192
|
+
KnowledgeArticle.apply_search_indexes!(wait: true)
|
|
193
|
+
|
|
194
|
+
# Managed embedding means ingestion is just `save`: each save that changes a
|
|
195
|
+
# source field makes one embedding call; unchanged re-saves make none.
|
|
196
|
+
CORPUS.each { |attrs| KnowledgeArticle.new(attrs).save }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def chat_loop(backend: :anthropic)
|
|
200
|
+
# Master posture (reads everything). Emits a one-time master-key warning to
|
|
201
|
+
# stderr; silence it with Parse::Agent.suppress_master_key_warning = true.
|
|
202
|
+
agent = Parse::Agent.new
|
|
203
|
+
|
|
204
|
+
puts "Ask a question (Ctrl-D to quit):"
|
|
205
|
+
while (question = $stdin.gets&.strip)
|
|
206
|
+
next if question.empty?
|
|
207
|
+
|
|
208
|
+
chunks = retrieve(agent, question)
|
|
209
|
+
answer = ChatAnswerer.public_send(backend, question, chunks)
|
|
210
|
+
|
|
211
|
+
puts "\n#{answer}\n"
|
|
212
|
+
sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
|
|
213
|
+
puts " (sources: #{sources})\n\n"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if __FILE__ == $PROGRAM_NAME
|
|
218
|
+
seed_corpus!
|
|
219
|
+
# Pick :openai or :anthropic for the generation step.
|
|
220
|
+
chat_loop(backend: :anthropic)
|
|
221
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Cloud Code Webhooks for parse-stack-next
|
|
5
|
+
#
|
|
6
|
+
# Webhooks are how a Ruby backend runs SERVER-SIDE trigger logic. Without them,
|
|
7
|
+
# a Parse::Object's ActiveModel callbacks (before_save, after_create, …) run
|
|
8
|
+
# ONLY in the Ruby process that initiated the save. A write that comes from a
|
|
9
|
+
# JS/Swift/REST client — or the Parse Dashboard — never touches your Ruby code,
|
|
10
|
+
# so that logic is silently skipped server-side.
|
|
11
|
+
#
|
|
12
|
+
# Registering a webhook flips that: Parse Server calls back into this Ruby app
|
|
13
|
+
# on the matching trigger, and your ActiveModel callbacks + webhook blocks run
|
|
14
|
+
# for EVERY client, not just Ruby ones.
|
|
15
|
+
#
|
|
16
|
+
# This file is a Rack app. Mount it (config.ru):
|
|
17
|
+
#
|
|
18
|
+
# require_relative "examples/webhook_server"
|
|
19
|
+
# run Parse::Webhooks
|
|
20
|
+
#
|
|
21
|
+
# and point Parse Server's `webhookKey` at the same value as Parse::Webhooks.key.
|
|
22
|
+
#
|
|
23
|
+
# See docs/webhooks_guide.md for the full picture (trigger types, the
|
|
24
|
+
# ActiveModel↔Parse hook relationship, latency, and replay protection).
|
|
25
|
+
|
|
26
|
+
require "parse-stack-next"
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# 1. Configure the (master-key) client + the webhook key
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
Parse.setup(
|
|
32
|
+
server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
|
|
33
|
+
app_id: ENV.fetch("PARSE_APP_ID"),
|
|
34
|
+
api_key: ENV.fetch("PARSE_REST_KEY"),
|
|
35
|
+
master_key: ENV.fetch("PARSE_MASTER_KEY"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Shared secret Parse Server sends as `X-Parse-Webhook-Key`. Set the same value
|
|
39
|
+
# in Parse Server's `webhookKey` option. Requests without it are rejected.
|
|
40
|
+
Parse::Webhooks.key = ENV.fetch("PARSE_WEBHOOK_KEY")
|
|
41
|
+
|
|
42
|
+
# Optional: server-initiated replay/freshness protection (see the guide). The
|
|
43
|
+
# body+request-id dedup is always on; an HMAC signing secret adds freshness
|
|
44
|
+
# verification of X-Parse-Webhook-Timestamp / X-Parse-Webhook-Signature.
|
|
45
|
+
Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# 2. A model with both ActiveModel callbacks AND webhook blocks
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
class Post < Parse::Object
|
|
51
|
+
property :title, :string, required: true
|
|
52
|
+
property :slug, :string
|
|
53
|
+
property :published, :boolean, default: false
|
|
54
|
+
|
|
55
|
+
# ActiveModel callbacks. For a Ruby-initiated save these run locally; for a
|
|
56
|
+
# NON-Ruby client they run here only if a beforeSave/afterSave webhook is
|
|
57
|
+
# registered for Post. Registering beforeSave enables BOTH before_save and
|
|
58
|
+
# before_create; afterSave enables both after_save and after_create.
|
|
59
|
+
before_save :normalize_slug
|
|
60
|
+
before_create { self.published = false } # created drafts start unpublished
|
|
61
|
+
after_create :enqueue_welcome # see note on afterSave below
|
|
62
|
+
|
|
63
|
+
def normalize_slug
|
|
64
|
+
self.slug = title.to_s.downcase.strip.gsub(/[^a-z0-9]+/, "-") if title_changed?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def enqueue_welcome
|
|
68
|
+
# AFTER-SAVE BEST PRACTICE: enqueue, don't execute. Parse Server blocks the
|
|
69
|
+
# client's write until this webhook returns — even though afterSave's return
|
|
70
|
+
# is a no-op — so long work here adds latency to every save. And do NOT save
|
|
71
|
+
# another object here if you can avoid it: each cascading save fires more
|
|
72
|
+
# webhooks (a latency cascade). Hand off to a background job instead.
|
|
73
|
+
# BackgroundJobs.enqueue(:index_post, id)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Post.auto_upgrade!
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# 3. Webhook blocks (optional) — server-side logic without a Ruby model callback
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# A block runs in the scope of a Parse::Webhooks::Payload. A beforeSave block
|
|
83
|
+
# returns `parse_object` (the SDK turns it into the changes Parse Server wants);
|
|
84
|
+
# returning `false` halts the save.
|
|
85
|
+
class Post
|
|
86
|
+
webhook :before_save do
|
|
87
|
+
# `parse_object` is the incoming object; mutate it, then return it.
|
|
88
|
+
parse_object
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# afterSave/afterDelete blocks may register more than one handler.
|
|
92
|
+
webhook :after_save do
|
|
93
|
+
# keep this short or enqueue — see enqueue_welcome above.
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# NOTE: there is no `webhook :before_create` / `:after_create`. Parse Server has
|
|
99
|
+
# no such trigger — register beforeSave/afterSave and your create callbacks fire
|
|
100
|
+
# within them for new objects. Asking for a create webhook raises with guidance.
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# 4. Register the webhooks with Parse Server (run once at deploy, needs master key)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# `endpoint` is the public HTTPS URL where THIS Rack app is reachable.
|
|
106
|
+
if ENV["PARSE_WEBHOOK_ENDPOINT"]
|
|
107
|
+
endpoint = ENV.fetch("PARSE_WEBHOOK_ENDPOINT") # e.g. https://hooks.example.com/webhooks
|
|
108
|
+
Parse::Webhooks.register_functions!(endpoint)
|
|
109
|
+
Parse::Webhooks.register_triggers!(endpoint)
|
|
110
|
+
puts "Registered webhooks at #{endpoint}"
|
|
111
|
+
end
|