Skip to content

Commit 57c8b6f

Browse files
committed
add blog page for each feature. Update api conventions
precommit
1 parent 31eb7ab commit 57c8b6f

25 files changed

Lines changed: 326 additions & 21 deletions

apps/game_server_core/lib/game_server/api_conventions.ex

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,21 @@ defmodule GameServer.ApiConventions do
2121
message: String.t()
2222
}
2323

24-
@core "apps/game_server_core/lib"
25-
@web "apps/game_server_web/lib"
24+
# The same checks run in every host repo (polyglot, the starter), where
25+
# core/web live under deps and the host's own code under lib/ and
26+
# modules/*/lib - so the scan roots are discovered, not hardcoded. Only
27+
# directories that exist take part.
28+
@schema_roots ["apps/game_server_core/lib", "lib", "modules"]
29+
@source_roots ["apps/game_server_web/lib", "lib", "modules"]
30+
31+
defp schema_dirs, do: existing([dep_dir("game_server_core") | @schema_roots])
32+
defp source_dirs, do: existing([dep_dir("game_server_web") | @source_roots])
33+
34+
# In a host repo core/web are deps; their code obeys the rules too, and a
35+
# violation there means the host pulled a bad version - worth failing on.
36+
defp dep_dir(app), do: Path.join(["deps", app, "apps", app, "lib"])
37+
38+
defp existing(paths), do: paths |> Enum.filter(&File.dir?/1) |> Enum.uniq()
2639

2740
# Durations must name their unit. `_at` is for instants, not durations.
2841
@duration_units ~w(ms sec seconds min minutes hours days)
@@ -39,7 +52,8 @@ defmodule GameServer.ApiConventions do
3952
hyphenated_route_paths() ++
4053
nullable_string_schemas() ++
4154
hand_rolled_meta() ++
42-
hand_rolled_page_params())
55+
hand_rolled_page_params() ++
56+
stale_documented_routes())
4357
|> Enum.sort_by(&{&1.rule, &1.file, &1.line})
4458
end
4559

@@ -51,7 +65,10 @@ defmodule GameServer.ApiConventions do
5165
defp nullable_strings_not_coalesced do
5266
nullable = nullable_schema_fields()
5367

54-
for {file, line, text} <- source_lines(@web),
68+
for {file, line, text} <- source_lines(source_dirs()),
69+
# Only code that writes JSON responses: a `key: var.field` line in a
70+
# context is usually changeset attrs, where nil is fine.
71+
String.contains?(file, "_web/"),
5572
not live_view?(file),
5673
[_, key, _var, field] <- [Regex.run(~r/^\s*(\w+): ([a-z_]+)\.(\w+),?\s*$/, text)],
5774
Map.has_key?(nullable, field),
@@ -71,7 +88,7 @@ defmodule GameServer.ApiConventions do
7188
# every serializer. Such schemas encode through `GameServer.SchemaJSON`.
7289

7390
defp derived_encoders_with_nullable_strings do
74-
for {file, line, text} <- source_lines(@core),
91+
for {file, line, text} <- source_lines(schema_dirs()),
7592
String.contains?(text, "@derive {Jason.Encoder"),
7693
schema_has_nullable_string?(file) do
7794
%{
@@ -86,7 +103,7 @@ defmodule GameServer.ApiConventions do
86103
# ── R3: instants are `*_at`, and nothing else is ───────────────────────────
87104

88105
defp instants_not_suffixed do
89-
for {file, line, text} <- source_lines(@core),
106+
for {file, line, text} <- source_lines(schema_dirs()),
90107
[_, field] <- [Regex.run(~r/^\s*field :(\w+), :utc_datetime/, text)],
91108
not String.ends_with?(field, "_at") do
92109
%{
@@ -101,7 +118,7 @@ defmodule GameServer.ApiConventions do
101118
# ── R4: durations name their unit ──────────────────────────────────────────
102119

103120
defp durations_without_unit do
104-
for {file, line, text} <- source_lines(@core) ++ source_lines(@web),
121+
for {file, line, text} <- source_lines(schema_dirs() ++ source_dirs()),
105122
[_, name] <- [Regex.run(~r/^\s*setting\(?:(\w+), :integer/, text)],
106123
duration_name?(name),
107124
not unit_suffixed?(name) do
@@ -124,7 +141,7 @@ defmodule GameServer.ApiConventions do
124141
# ── R5: route paths use underscores ────────────────────────────────────────
125142

126143
defp hyphenated_route_paths do
127-
for {file, line, text} <- source_lines(@web),
144+
for {file, line, text} <- source_lines(source_dirs()),
128145
String.contains?(file, "router"),
129146
[_, verb, path] <-
130147
[Regex.run(~r/^\s*(get|post|put|patch|delete|live) "(\/[^"]*)"/, text)],
@@ -145,7 +162,7 @@ defmodule GameServer.ApiConventions do
145162
# semantic there (`ends_at: null` = permanent).
146163

147164
defp nullable_string_schemas do
148-
for {file, line, text} <- source_lines(@web),
165+
for {file, line, text} <- source_lines(source_dirs()),
149166
String.contains?(text, "nullable: true"),
150167
String.contains?(text, "type: :string"),
151168
not String.contains?(text, "format:"),
@@ -166,7 +183,7 @@ defmodule GameServer.ApiConventions do
166183
# emitting three, four and six keys.
167184

168185
defp hand_rolled_meta do
169-
for {file, line, text} <- source_lines(@web),
186+
for {file, line, text} <- source_lines(source_dirs()),
170187
controller?(file),
171188
Regex.match?(~r/^\s*(total_pages|has_more):/, text),
172189
not String.contains?(text, "%Schema{") do
@@ -185,7 +202,7 @@ defmodule GameServer.ApiConventions do
185202
# `min(size, 100)`, which ignored the configurable `max_page_size` limit.
186203

187204
defp hand_rolled_page_params do
188-
for {file, line, text} <- source_lines(@web),
205+
for {file, line, text} <- source_lines(source_dirs()),
189206
controller?(file),
190207
String.contains?(text, ~s(params["page_size"])) or
191208
String.contains?(text, ~s(params["page"])) do
@@ -200,6 +217,91 @@ defmodule GameServer.ApiConventions do
200217

201218
defp controller?(file), do: String.contains?(file, "/controllers/")
202219

220+
# ── R9: docs reference only routes that exist ──────────────────────────────
221+
#
222+
# Guides and specs name concrete `/api/v1/...` paths; a rename that misses
223+
# one leaves documentation teaching a 404. Prefixes of real routes are fine
224+
# ("endpoints live under /api/v1/chat"), as are file paths.
225+
226+
@doc_dirs ["priv/docs", "docs"]
227+
228+
defp stale_documented_routes do
229+
case declared_route_paths() do
230+
[] -> []
231+
declared -> stale_documented_routes(declared)
232+
end
233+
end
234+
235+
defp stale_documented_routes(declared) do
236+
for dir <- @doc_dirs,
237+
file <- Path.wildcard(Path.join(dir, "**/*.md")),
238+
{text, line} <- file |> File.read!() |> String.split("\n") |> Enum.with_index(1),
239+
path <- documented_api_paths(text),
240+
not known_or_prefix?(path, declared) do
241+
%{
242+
rule: "R9-doc-route",
243+
file: file,
244+
line: line,
245+
message: "documents `#{path}`, which matches no declared route"
246+
}
247+
end
248+
end
249+
250+
defp documented_api_paths(line) do
251+
~r|/api/v1/[A-Za-z0-9_\-/:{}]*[A-Za-z0-9_}]|
252+
|> Regex.scan(line)
253+
|> List.flatten()
254+
|> Enum.reject(&(String.contains?(&1, "controller") or String.ends_with?(&1, ".ex")))
255+
|> Enum.map(&normalize_path/1)
256+
end
257+
258+
defp normalize_path(path) do
259+
Regex.replace(~r/(:\w+|\{\w+\})/, path, "{}")
260+
end
261+
262+
# A documented path is fine when a declared route matches it — params on
263+
# either side are wildcards, so `/payments/validate/google` satisfies
264+
# `/payments/validate/{}` — or when it is a prefix of one ("endpoints live
265+
# under /api/v1/chat").
266+
defp known_or_prefix?(path, declared) do
267+
Enum.any?(declared, fn route ->
268+
Regex.match?(wildcard_regex(route, ""), path) or
269+
Regex.match?(wildcard_regex(path, "(/|$)"), route)
270+
end)
271+
end
272+
273+
defp wildcard_regex(pattern, tail) do
274+
inner =
275+
pattern
276+
|> Regex.escape()
277+
|> String.replace("\\{\\}", "[^/]+")
278+
279+
Regex.compile!("^" <> inner <> if(tail == "", do: "$", else: tail))
280+
end
281+
282+
@doc """
283+
Every path the compiled router serves, `:params` as `{}`.
284+
285+
Read from `Phoenix.Router.routes/1` rather than parsed from source — routes
286+
live inside `scope` blocks, so the literal strings in the source are
287+
suffixes, not full paths.
288+
"""
289+
@spec declared_route_paths() :: [String.t()]
290+
def declared_route_paths do
291+
[GameServerHost.Router, GameServerWeb.Router]
292+
|> Enum.find(&Code.ensure_loaded?/1)
293+
|> case do
294+
nil ->
295+
[]
296+
297+
router ->
298+
router
299+
|> Phoenix.Router.routes()
300+
|> Enum.map(&normalize_path(&1.path))
301+
|> Enum.uniq()
302+
end
303+
end
304+
203305
# ── Shared analysis ────────────────────────────────────────────────────────
204306

205307
@doc """
@@ -209,7 +311,7 @@ defmodule GameServer.ApiConventions do
209311
@spec nullable_schema_fields() :: %{String.t() => String.t()}
210312
def nullable_schema_fields do
211313
per_schema =
212-
for path <- ex_files(@core),
314+
for path <- ex_files(schema_dirs()),
213315
[_, table, body] <- [Regex.run(~r/schema "(\w+)" do(.*?)\n end/s, File.read!(path))] do
214316
required = required_fields(File.read!(path))
215317

@@ -271,13 +373,24 @@ defmodule GameServer.ApiConventions do
271373
# policy is about what crosses the wire as JSON.
272374
defp live_view?(file), do: String.contains?(file, "/live/")
273375

274-
defp source_lines(root) do
275-
for path <- ex_files(root),
376+
defp source_lines(roots) do
377+
for path <- ex_files(roots),
276378
{text, line} <- Enum.with_index(String.split(File.read!(path), "\n"), 1),
277379
do: {path, line, text}
278380
end
279381

280-
defp ex_files(root) do
281-
Path.wildcard(Path.join(root, "**/*.ex"))
382+
defp ex_files(roots) when is_list(roots) do
383+
roots
384+
|> Enum.flat_map(fn root ->
385+
# A root may itself live under deps/ (core/web pulled into a host), but
386+
# anything *nested* below a root - a plugin's own deps/_build - is
387+
# third-party code and not ours to lint.
388+
for path <- Path.wildcard(Path.join(root, "**/*.ex")),
389+
rest = String.trim_leading(path, root),
390+
not String.contains?(rest, "/deps/"),
391+
not String.contains?(rest, "/_build/"),
392+
do: path
393+
end)
394+
|> Enum.uniq()
282395
end
283396
end

apps/game_server_core/lib/game_server/content.ex

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ defmodule GameServer.Content do
403403
post ->
404404
case render_markdown_file(post.path, "blog") do
405405
nil -> nil
406-
html -> strip_first_h1(html)
406+
html -> html |> strip_first_h1() |> strip_lede_paragraph(post.excerpt)
407407
end
408408
end
409409
end)
@@ -765,6 +765,33 @@ defmodule GameServer.Content do
765765
Regex.replace(~r/<h1>.*?<\/h1>\s*/s, html, "", global: false)
766766
end
767767

768+
# The show page renders the excerpt as a lede above the body, and the excerpt
769+
# *is* the body's first paragraph — so that paragraph is dropped here or every
770+
# post opens by repeating itself. Only an exact match is removed; an edited
771+
# opening paragraph stays.
772+
defp strip_lede_paragraph(html, excerpt) when is_binary(excerpt) and excerpt != "" do
773+
case Regex.run(~r/\A\s*<p>(.*?)<\/p>\s*/s, html) do
774+
[full, text] ->
775+
if normalize_text(text) == normalize_text(excerpt) do
776+
String.replace(html, full, "", global: false)
777+
else
778+
html
779+
end
780+
781+
_ ->
782+
html
783+
end
784+
end
785+
786+
defp strip_lede_paragraph(html, _excerpt), do: html
787+
788+
defp normalize_text(text) do
789+
text
790+
|> String.replace(~r/<[^>]+>/, "")
791+
|> String.replace(~r/\s+/, " ")
792+
|> String.trim()
793+
end
794+
768795
# Pill tag definitions: [tag] → {css_class_suffix, display_label}
769796
@changelog_tags %{
770797
"fix" => {"fix", "Fix"},

apps/game_server_core/lib/mix/tasks/gamend.api.lint.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ defmodule Mix.Tasks.Gamend.Api.Lint do
2323
{"R5-path-underscore", "A route path contains a hyphen"},
2424
{"R6-schema-nullable", "An OpenAPI string field declares `nullable: true`"},
2525
{"R7-meta-helper", "A controller builds pagination meta by hand"},
26-
{"R8-page-params", "A controller reads page params without Pagination.params/1"}
26+
{"R8-page-params", "A controller reads page params without Pagination.params/1"},
27+
{"R9-doc-route", "A guide/spec documents an API route that does not exist"}
2728
]
2829

2930
@impl true

apps/game_server_web/config/test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ config :game_server_web, GameServerWeb.Plugs.RateLimiter, enabled: false
7777
# keep logging after the test task itself is done.
7878
config :game_server_core, GameServer.Accounts.StalePresenceSweeper, enabled: false
7979

80+
# Run GameServer.Async side effects inline so assertions observe them without
81+
# racing, and so a fire-and-forget task can't outlive the test's DB sandbox
82+
# owner (that surfaced as "client #PID exited" Exqlite disconnects). Kept in
83+
# sync with the root config/test.exs, which only applies to umbrella runs.
84+
config :game_server_core, async_inline: true
85+
8086
# Jobs run inline on demand in tests (no queues/plugins/cron). Kept in sync with
8187
# the root config/test.exs.
8288
config :game_server_core, Oban, testing: :manual

apps/game_server_web/test/game_server_web/controllers/api/v1/admin/economy_controller_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ defmodule GameServerWeb.Api.V1.Admin.EconomyControllerTest do
8181
assert [%{"item" => "potion", "quantity" => 3}] = items["data"]
8282
end
8383

84-
test "admin consume-item refuses to overdraw", %{conn: conn, target: target} do
84+
test "admin consume_item refuses to overdraw", %{conn: conn, target: target} do
8585
conn =
8686
post(conn, "/api/v1/admin/economy/consume_item", %{
8787
user_id: target.id,

apps/game_server_web/test/game_server_web/controllers/api/v1/admin/icon_upload_test.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ defmodule GameServerWeb.Api.V1.Admin.IconUploadTest do
99

1010
use GameServerWeb.ConnCase, async: false
1111

12-
alias GameServer.Accounts
1312
alias GameServer.Accounts.User
1413
alias GameServer.AccountsFixtures
1514
alias GameServer.Leaderboards

apps/game_server_web/test/game_server_web/controllers/api/v1/tournament_controller_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ defmodule GameServerWeb.Api.V1.TournamentControllerTest do
155155
|> json_response(404)
156156
end
157157

158-
test "bracket, standings and my-match after a draw", %{
158+
test "bracket, standings and my_match after a draw", %{
159159
auth_conn: auth_conn,
160160
conn: conn,
161161
user: user,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Ops: Settings, Storage, Jobs, Retention
2+
3+
Added one library settings where every env var name is derived from code. Added object storage that uses both local disk and S3/R2 API. Updated the background jobs to use Oban, and retention for every unbounded table.
4+
5+
## Settings
6+
7+
Every setting the server understands is now *declared* in code.
8+
9+
```elixir
10+
setting :min_password_length, :integer,
11+
default: 8,
12+
doc: "Minimum password length."
13+
```
14+
15+
Settings are `required`, `warn` or `optional`.
16+
17+
![](ops/settings.png)
18+
19+
## Object storage
20+
21+
Avatars and icons go through one storage facade with two backends: `local disk` and any `S3-compatible` service. Everything is inspectable in admin:
22+
23+
![](ops/storage.png)
24+
25+
## Jobs and retention
26+
27+
Background work — push fan-out, mailers, webhooks, pruning — runs on Oban, with the full dashboard mounted at `/admin/oban`:
28+
29+
![](ops/oban.png)
30+
31+
- [Settings reference](https://gamend.appsinacup.com/docs/setup)
32+
- [Github](https://github.com/appsinacup/game_server)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Economy: Wallets, Ledger, Inventory
2+
3+
Virtual currencies with an append-only ledger, an item inventory, and payment providers (Stripe, Google Play, App Store, Steam) that grant entitlements server-side.
4+
5+
## Wallets and the ledger
6+
7+
A user has one wallet per currency, and every movement is a ledger row with a reason.
8+
9+
```elixir
10+
Economy.grant(user_id, "gold", 100, reason: "match_reward")
11+
12+
case Economy.spend(user_id, "gold", 30, reason: "store_purchase") do
13+
{:ok, wallet} -> deliver_item(user_id)
14+
{:error, :insufficient_funds} -> :nope
15+
end
16+
```
17+
18+
![](economy/wallets.png)
19+
20+
- [Github](https://github.com/appsinacup/game_server)

0 commit comments

Comments
 (0)