feat(agent-discovery): Link/markdown/content-signal/api-catalog/skills
Implements the subset of the isitagentready.com checklist that maps to
real capabilities of this site:
- RFC 8288 Link headers on every browser response advertising
service-doc (/algo), about, privacy-policy, and sitemap.
- Markdown-for-Agents content negotiation: requests with
Accept: text/markdown for / or /algo return real markdown
(curated summary + verbatim algo.md) with x-markdown-tokens hint.
Other paths still 406 — no synthesizing markdown from LiveView HTML.
- Content-Signal directive in robots.txt declaring
search=yes, ai-train=no, ai-input=no.
- RFC 9727 API catalog at /.well-known/api-catalog with the sole
public endpoint (/api/contacts/map) linking status -> /health and
service-desc -> /openapi.json (a new minimal OpenAPI 3.0 spec).
- Agent Skills Discovery index at /.well-known/agent-skills/index.json
listing two skill documents (fetch-contacts, read-algorithm) with
sha256 digests computed at compile time; documents served at
/.well-known/agent-skills/{name}/SKILL.md.
Skipped (don't match actual site capabilities): Web Bot Auth JWKS,
OAuth/OIDC discovery, MCP server card.
This commit is contained in:
parent
6972feb2c2
commit
fc9d0dc977
9 changed files with 489 additions and 0 deletions
72
lib/microwaveprop_web/controllers/agent_skills_controller.ex
Normal file
72
lib/microwaveprop_web/controllers/agent_skills_controller.ex
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
defmodule MicrowavepropWeb.AgentSkillsController do
|
||||
@moduledoc """
|
||||
Publishes an Agent Skills Discovery (v0.2.0) index at
|
||||
`/.well-known/agent-skills/index.json` plus the referenced skill
|
||||
documents. Each document is a short markdown file describing one
|
||||
concrete task an agent can perform against this service's public
|
||||
endpoints. Digests are computed at compile time from the source files.
|
||||
"""
|
||||
use MicrowavepropWeb, :controller
|
||||
|
||||
@schema_url "https://agentskills.io/schemas/v0.2.0/skills.schema.json"
|
||||
|
||||
@skill_sources [
|
||||
%{
|
||||
name: "fetch-contacts",
|
||||
description:
|
||||
"Retrieve every amateur-radio contact in the propagation calibration dataset as a JSON array of compact tuples.",
|
||||
path: "priv/agent-skills/fetch-contacts.md"
|
||||
},
|
||||
%{
|
||||
name: "read-algorithm",
|
||||
description:
|
||||
"Obtain the full NTMS propagation scoring algorithm documentation, including factor weights and ITU-R model references, as markdown.",
|
||||
path: "priv/agent-skills/read-algorithm.md"
|
||||
}
|
||||
]
|
||||
|
||||
for %{path: path} <- @skill_sources do
|
||||
@external_resource path
|
||||
end
|
||||
|
||||
@skills Enum.map(@skill_sources, fn %{name: name, description: desc, path: path} ->
|
||||
body = File.read!(path)
|
||||
sha = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower)
|
||||
%{name: name, description: desc, body: body, sha256: sha}
|
||||
end)
|
||||
|
||||
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def index(conn, _params) do
|
||||
entries =
|
||||
Enum.map(@skills, fn s ->
|
||||
%{
|
||||
name: s.name,
|
||||
type: "text/markdown",
|
||||
description: s.description,
|
||||
url: url(conn, ~p"/.well-known/agent-skills/#{s.name}/SKILL.md"),
|
||||
sha256: s.sha256
|
||||
}
|
||||
end)
|
||||
|
||||
body = Jason.encode!(%{"$schema" => @schema_url, skills: entries})
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
@spec skill(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def skill(conn, %{"name" => name}) do
|
||||
case Enum.find(@skills, &(&1.name == name)) do
|
||||
nil ->
|
||||
send_resp(conn, 404, "")
|
||||
|
||||
s ->
|
||||
conn
|
||||
|> put_resp_content_type("text/markdown", "utf-8")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> send_resp(200, s.body)
|
||||
end
|
||||
end
|
||||
end
|
||||
83
lib/microwaveprop_web/controllers/api_catalog_controller.ex
Normal file
83
lib/microwaveprop_web/controllers/api_catalog_controller.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule MicrowavepropWeb.ApiCatalogController do
|
||||
@moduledoc """
|
||||
RFC 9727 API catalog + companion OpenAPI spec for automated API discovery.
|
||||
|
||||
`/api/contacts/map` is currently the only public JSON endpoint. The
|
||||
catalog lists it with link relations for `service-desc` (OpenAPI) and
|
||||
`status` (health check). If more endpoints are added, extend
|
||||
`linkset_entries/1` and the OpenAPI `paths` map.
|
||||
"""
|
||||
use MicrowavepropWeb, :controller
|
||||
|
||||
@openapi_type "application/vnd.oai.openapi+json;version=3.0"
|
||||
|
||||
@spec catalog(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def catalog(conn, _params) do
|
||||
body = Jason.encode!(%{linkset: linkset_entries(conn)})
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/linkset+json")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
@spec openapi(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def openapi(conn, _params) do
|
||||
body = Jason.encode!(openapi_spec(conn))
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> put_resp_header("cache-control", "public, max-age=3600")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
defp linkset_entries(conn) do
|
||||
[
|
||||
%{
|
||||
anchor: url(conn, ~p"/api/contacts/map"),
|
||||
"service-desc": [%{href: url(conn, ~p"/openapi.json"), type: @openapi_type}],
|
||||
status: [%{href: url(conn, ~p"/health")}]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp openapi_spec(conn) do
|
||||
%{
|
||||
openapi: "3.0.3",
|
||||
info: %{
|
||||
title: "Microwaveprop Public API",
|
||||
description: "Public JSON endpoints for the NTMS microwave propagation prediction service.",
|
||||
version: "1.0.0"
|
||||
},
|
||||
servers: [%{url: url(conn, ~p"/")}],
|
||||
paths: %{
|
||||
"/api/contacts/map" => %{
|
||||
get: %{
|
||||
summary: "All recorded amateur-radio contacts as compact tuples",
|
||||
description:
|
||||
"Returns every contact in the calibration dataset as an array of fixed-shape tuples. " <>
|
||||
"Response is pre-gzipped when the client sends Accept-Encoding: gzip. " <>
|
||||
"Tuple shape: [lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp, id].",
|
||||
responses: %{
|
||||
"200" => %{
|
||||
description: "Array of contact tuples",
|
||||
content: %{
|
||||
"application/json" => %{
|
||||
schema: %{
|
||||
type: "array",
|
||||
items: %{
|
||||
type: "array",
|
||||
minItems: 11,
|
||||
maxItems: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
pipeline :browser do
|
||||
plug :serve_markdown_if_requested
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :store_remote_ip
|
||||
|
|
@ -14,9 +15,97 @@ defmodule MicrowavepropWeb.Router do
|
|||
plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :put_agent_link_headers
|
||||
plug :fetch_current_scope_for_user
|
||||
end
|
||||
|
||||
# RFC 8288 Link headers advertising resources for agent/crawler discovery.
|
||||
# `/algo` is the human-readable algorithm/service documentation, rendered
|
||||
# from `algo.md`. `/sitemap.xml` is the registered "sitemap" rel. About
|
||||
# and privacy use their IANA-registered rels.
|
||||
@agent_link_header Enum.join(
|
||||
[
|
||||
~s(</algo>; rel="service-doc"),
|
||||
~s(</about>; rel="about"),
|
||||
~s(</privacy>; rel="privacy-policy"),
|
||||
~s(</sitemap.xml>; rel="sitemap")
|
||||
],
|
||||
", "
|
||||
)
|
||||
|
||||
defp put_agent_link_headers(conn, _opts) do
|
||||
Plug.Conn.put_resp_header(conn, "link", @agent_link_header)
|
||||
end
|
||||
|
||||
# Markdown for Agents: if the client prefers `text/markdown`, serve real
|
||||
# markdown for the paths where we have markdown source. Other paths fall
|
||||
# through to the `:accepts` plug and 406 — we don't synthesize markdown
|
||||
# from LiveView HTML on the fly, since a lossy conversion would mislead
|
||||
# agents about site content.
|
||||
@external_resource "algo.md"
|
||||
@algo_markdown File.read!("algo.md")
|
||||
|
||||
@homepage_markdown """
|
||||
# NTMS Propagation Prediction
|
||||
|
||||
The North Texas Microwave Society's propagation prediction service for
|
||||
amateur radio bands 10–241 GHz. Scores propagation conditions across
|
||||
CONUS using HRRR numerical weather prediction, sounding data, ITU-R
|
||||
atmospheric models, and calibration against 57,000+ recorded QSOs.
|
||||
|
||||
## Primary Resources
|
||||
|
||||
- [Propagation map](/map) — real-time CONUS propagation scores + 18-hour forecast.
|
||||
- [Algorithm documentation](/algo) — full methodology, factor weights, ITU-R references (also available as `text/markdown`).
|
||||
- [Contact database](/contacts) — 58,000+ historical QSOs used for calibration.
|
||||
- [Contact map](/contacts/map) — geographic view of all recorded contacts.
|
||||
- [Path calculator](/path) — point-to-point propagation prediction.
|
||||
- [Beacons](/beacons) — microwave beacon directory.
|
||||
- [Submit a contact](/submit) — add a QSO to the calibration dataset.
|
||||
|
||||
## Meta
|
||||
|
||||
- [About](/about)
|
||||
- [Privacy](/privacy)
|
||||
- [Sitemap](/sitemap.xml)
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/contacts/map` — JSON tuples of every contact on the map (lat/lon/band/callsigns/mode/distance/timestamp/id). Pre-gzipped, ~5 MB decompressed.
|
||||
"""
|
||||
|
||||
defp serve_markdown_if_requested(conn, _opts) do
|
||||
if wants_markdown?(conn) do
|
||||
case markdown_for_path(conn.request_path) do
|
||||
nil ->
|
||||
conn
|
||||
|
||||
body ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/markdown", "utf-8")
|
||||
|> Plug.Conn.put_resp_header("x-markdown-tokens", estimate_tokens(body))
|
||||
|> Plug.Conn.send_resp(200, body)
|
||||
|> Plug.Conn.halt()
|
||||
end
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp wants_markdown?(conn) do
|
||||
conn
|
||||
|> Plug.Conn.get_req_header("accept")
|
||||
|> Enum.any?(&String.contains?(&1, "text/markdown"))
|
||||
end
|
||||
|
||||
defp markdown_for_path("/"), do: @homepage_markdown
|
||||
defp markdown_for_path("/algo"), do: @algo_markdown
|
||||
defp markdown_for_path(_), do: nil
|
||||
|
||||
# Rough token estimate: ~4 chars per token is the widely-used OpenAI
|
||||
# heuristic. Good enough for the `x-markdown-tokens` hint.
|
||||
defp estimate_tokens(body), do: body |> byte_size() |> div(4) |> Integer.to_string()
|
||||
|
||||
defp store_remote_ip(conn, _opts) do
|
||||
ip_str = conn.remote_ip |> :inet.ntoa() |> to_string()
|
||||
Plug.Conn.put_session(conn, :remote_ip, ip_str)
|
||||
|
|
@ -46,6 +135,18 @@ defmodule MicrowavepropWeb.Router do
|
|||
# Health check — no pipeline, minimal overhead
|
||||
get "/health", MicrowavepropWeb.HealthController, :check, log: false
|
||||
|
||||
# API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline,
|
||||
# no session — agents and crawlers fetch them without auth.
|
||||
get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog
|
||||
get "/openapi.json", MicrowavepropWeb.ApiCatalogController, :openapi
|
||||
|
||||
# Agent Skills Discovery (v0.2.0). Index + individual skill documents.
|
||||
get "/.well-known/agent-skills/index.json", MicrowavepropWeb.AgentSkillsController, :index
|
||||
|
||||
get "/.well-known/agent-skills/:name/SKILL.md",
|
||||
MicrowavepropWeb.AgentSkillsController,
|
||||
:skill
|
||||
|
||||
# Admin-only routes must come first so that literal segments like
|
||||
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
|
||||
scope "/", MicrowavepropWeb do
|
||||
|
|
|
|||
32
priv/agent-skills/fetch-contacts.md
Normal file
32
priv/agent-skills/fetch-contacts.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Fetch contacts dataset
|
||||
|
||||
The NTMS microwave propagation service publishes its full amateur-radio contact (QSO) calibration dataset as a single public JSON endpoint. No authentication is required.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET https://prop.w5isp.com/api/contacts/map
|
||||
```
|
||||
|
||||
The response is pre-gzipped when the client sends `Accept-Encoding: gzip` (roughly 1 MB compressed, 5 MB decompressed). Cached at the origin for 60 s.
|
||||
|
||||
## Response shape
|
||||
|
||||
A single JSON array where each element is a fixed-length tuple:
|
||||
|
||||
```
|
||||
[lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp_iso8601, id_uuid]
|
||||
```
|
||||
|
||||
- `lat1/lon1` and `lat2/lon2` are the two station locations (WGS84 degrees).
|
||||
- `band_mhz` is an integer in MHz (e.g. `1296`, `10000`, `24000`).
|
||||
- `callsign1/callsign2` are uppercase amateur callsigns or `null` if unknown.
|
||||
- `mode` is a string (`CW`, `SSB`, `FT8`, …) or `null`.
|
||||
- `distance_km` is a number or `null`.
|
||||
- `qso_timestamp_iso8601` is `YYYY-MM-DDTHH:MM:SSZ`.
|
||||
- `id_uuid` is the stable UUID for the contact; fetch details at `/contacts/{id_uuid}`.
|
||||
|
||||
## Machine-readable specs
|
||||
|
||||
- OpenAPI 3.0 spec: `GET /openapi.json`
|
||||
- API catalog (RFC 9727): `GET /.well-known/api-catalog`
|
||||
27
priv/agent-skills/read-algorithm.md
Normal file
27
priv/agent-skills/read-algorithm.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Read propagation algorithm documentation
|
||||
|
||||
The scoring algorithm for amateur-radio microwave propagation (902 MHz – 241 GHz) is documented in long-form markdown, covering factor weights, band-specific configurations, ITU-R atmospheric models, and calibration dataset composition.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET https://prop.w5isp.com/algo
|
||||
```
|
||||
|
||||
The default response is HTML. Send `Accept: text/markdown` to receive the raw markdown source directly:
|
||||
|
||||
```
|
||||
GET https://prop.w5isp.com/algo
|
||||
Accept: text/markdown
|
||||
```
|
||||
|
||||
Response: `Content-Type: text/markdown; charset=utf-8`. An `x-markdown-tokens` header gives a rough token-count estimate for context-window planning.
|
||||
|
||||
## Content summary
|
||||
|
||||
- Scoring methodology: 10 weighted factors combining to a 0–100 composite score per grid point per band.
|
||||
- Factor weights (humidity 13.6 %, time of day 5.0 %, T-Td depression 9.8 %, refractivity 10.5 %, sky 8.0 %, season 11.1 %, wind 8.0 %, rain 13.6 %, pressure 10.3 %, PWAT 11.3 %) recalibrated 2026-04-11 via gradient descent against 57,488 tropospheric QSOs.
|
||||
- Band-dependent humidity effect: beneficial at 10 GHz (refractivity gradients), harmful at 24 GHz+ (gaseous absorption).
|
||||
- ITU-R model references: P.838-3 (rain attenuation), P.526-16 (diffraction), P.676 (gaseous absorption).
|
||||
- Terrain analysis: 97.2 % of all analysed QSO paths are terrain-blocked; atmospheric ducting and refraction are the primary enabling mechanisms.
|
||||
- Calibration dataset: 58 k+ QSOs, HRRR atmospheric profiles, RAOB soundings, IEMRE gridded observations, SRTM terrain, and commercial link SNMP polling at 11/24/68 GHz.
|
||||
|
|
@ -4,5 +4,6 @@ Disallow: /backfill
|
|||
Disallow: /dev/
|
||||
Disallow: /dashboard
|
||||
Disallow: /health
|
||||
Content-Signal: search=yes, ai-train=no, ai-input=no
|
||||
|
||||
Sitemap: https://prop.w5isp.com/sitemap.xml
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
defmodule MicrowavepropWeb.AgentSkillsControllerTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
describe "GET /.well-known/agent-skills/index.json" do
|
||||
test "returns the agent-skills discovery index", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/agent-skills/index.json")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["application/json" <> _] = get_resp_header(conn, "content-type")
|
||||
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["$schema"] =~ "agentskills"
|
||||
assert is_list(body["skills"])
|
||||
assert body["skills"] != []
|
||||
|
||||
for skill <- body["skills"] do
|
||||
assert is_binary(skill["name"])
|
||||
assert is_binary(skill["type"])
|
||||
assert is_binary(skill["description"])
|
||||
assert is_binary(skill["url"])
|
||||
assert String.match?(skill["sha256"], ~r/^[0-9a-f]{64}$/)
|
||||
end
|
||||
end
|
||||
|
||||
test "index lists at least fetch-contacts and read-algorithm", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/agent-skills/index.json")
|
||||
%{"skills" => skills} = Jason.decode!(conn.resp_body)
|
||||
names = Enum.map(skills, & &1["name"])
|
||||
|
||||
assert "fetch-contacts" in names
|
||||
assert "read-algorithm" in names
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /.well-known/agent-skills/:name/SKILL.md" do
|
||||
test "serves a skill document as markdown", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/agent-skills/fetch-contacts/SKILL.md")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
|
||||
assert conn.resp_body =~ "Fetch contacts dataset"
|
||||
end
|
||||
|
||||
test "sha256 in the index matches the served document bytes", %{conn: conn} do
|
||||
%{"skills" => skills} =
|
||||
conn |> get("/.well-known/agent-skills/index.json") |> Map.get(:resp_body) |> Jason.decode!()
|
||||
|
||||
for %{"name" => name, "sha256" => expected} <- skills do
|
||||
doc = get(conn, "/.well-known/agent-skills/#{name}/SKILL.md").resp_body
|
||||
actual = :sha256 |> :crypto.hash(doc) |> Base.encode16(case: :lower)
|
||||
assert actual == expected, "sha256 mismatch for skill #{name}"
|
||||
end
|
||||
end
|
||||
|
||||
test "returns 404 for unknown skill names", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/agent-skills/does-not-exist/SKILL.md")
|
||||
assert conn.status == 404
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
defmodule MicrowavepropWeb.ApiCatalogControllerTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
describe "GET /.well-known/api-catalog (RFC 9727)" do
|
||||
test "returns application/linkset+json with a linkset array", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/api-catalog")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["application/linkset+json" <> _] = get_resp_header(conn, "content-type")
|
||||
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert is_list(body["linkset"])
|
||||
assert body["linkset"] != []
|
||||
end
|
||||
|
||||
test "first entry anchors the contacts map endpoint with status and service-desc", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/api-catalog")
|
||||
%{"linkset" => [entry | _]} = Jason.decode!(conn.resp_body)
|
||||
|
||||
assert entry["anchor"] =~ "/api/contacts/map"
|
||||
|
||||
assert [%{"href" => status_href}] = entry["status"]
|
||||
assert status_href =~ "/health"
|
||||
|
||||
assert [%{"href" => desc_href, "type" => desc_type}] = entry["service-desc"]
|
||||
assert desc_href =~ "/openapi.json"
|
||||
assert desc_type == "application/vnd.oai.openapi+json;version=3.0"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /openapi.json" do
|
||||
test "returns a valid OpenAPI 3.x document describing the public API", %{conn: conn} do
|
||||
conn = get(conn, "/openapi.json")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["application/json" <> _] = get_resp_header(conn, "content-type")
|
||||
|
||||
spec = Jason.decode!(conn.resp_body)
|
||||
assert spec["openapi"] =~ ~r/^3\./
|
||||
assert is_map(spec["info"])
|
||||
assert is_binary(spec["info"]["title"])
|
||||
assert is_binary(spec["info"]["version"])
|
||||
assert Map.has_key?(spec["paths"], "/api/contacts/map")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,4 +5,71 @@ defmodule MicrowavepropWeb.PageControllerTest do
|
|||
conn = get(conn, ~p"/")
|
||||
assert redirected_to(conn) == "/map"
|
||||
end
|
||||
|
||||
# RFC 8288 Link headers for agent discovery. These advertise resources
|
||||
# that actually exist in this app to crawlers/agents that look at
|
||||
# response headers before parsing HTML.
|
||||
describe "Link headers (RFC 8288)" do
|
||||
test "homepage response carries Link headers", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
[link] = get_resp_header(conn, "link")
|
||||
|
||||
assert link =~ ~s(</algo>; rel="service-doc")
|
||||
assert link =~ ~s(</about>; rel="about")
|
||||
assert link =~ ~s(</privacy>; rel="privacy-policy")
|
||||
assert link =~ ~s(</sitemap.xml>; rel="sitemap")
|
||||
end
|
||||
|
||||
test "browser-pipeline pages carry the same Link headers", %{conn: conn} do
|
||||
conn = get(conn, ~p"/about")
|
||||
[link] = get_resp_header(conn, "link")
|
||||
assert link =~ ~s(rel="service-doc")
|
||||
assert link =~ ~s(rel="about")
|
||||
end
|
||||
end
|
||||
|
||||
# Markdown for Agents: content negotiation so agents that send
|
||||
# `Accept: text/markdown` get a real markdown document instead of a 406.
|
||||
# We only serve markdown for paths where we actually have markdown source
|
||||
# (the homepage summary and /algo). Other paths still 406 rather than lie
|
||||
# with a converted-on-the-fly body.
|
||||
describe "Markdown for Agents" do
|
||||
test "GET / with Accept: text/markdown returns a markdown document", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept", "text/markdown")
|
||||
|> get(~p"/")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
|
||||
[tokens] = get_resp_header(conn, "x-markdown-tokens")
|
||||
assert String.to_integer(tokens) > 0
|
||||
assert conn.resp_body =~ "# "
|
||||
end
|
||||
|
||||
test "GET /algo with Accept: text/markdown returns algo.md", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept", "text/markdown")
|
||||
|> get(~p"/algo")
|
||||
|
||||
assert conn.status == 200
|
||||
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
|
||||
assert conn.resp_body =~ "Microwave Propagation Algorithm"
|
||||
end
|
||||
|
||||
test "GET / without markdown Accept still redirects to /map", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert redirected_to(conn) == "/map"
|
||||
end
|
||||
|
||||
test "Accept lists with both html and markdown prefer markdown when specified", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept", "text/markdown, text/html;q=0.9")
|
||||
|> get(~p"/")
|
||||
|
||||
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue