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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/server-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ on:
- tests/Unit/ServerParityWorkerListingContractTest.mjs
- tests/Unit/ServerParityWorkerReplacementContractTest.mjs
- tests/Unit/ServerParityOperatorMetricsContractTest.mjs
- tests/Unit/ServerParityServiceCatalogContractTest.mjs
- tests/Unit/ServerParityWaitingHistoryContractTest.mjs
- tests/Unit/ServerParityTaskRetryContractTest.mjs
- tests/Unit/ServerParityPublishedPatchContractTest.mjs
Expand Down Expand Up @@ -81,6 +82,7 @@ on:
- tests/Unit/ServerParityWorkerListingContractTest.mjs
- tests/Unit/ServerParityWorkerReplacementContractTest.mjs
- tests/Unit/ServerParityOperatorMetricsContractTest.mjs
- tests/Unit/ServerParityServiceCatalogContractTest.mjs
- tests/Unit/ServerParityWaitingHistoryContractTest.mjs
- tests/Unit/ServerParityTaskRetryContractTest.mjs
- tests/Unit/ServerParityPublishedPatchContractTest.mjs
Expand Down Expand Up @@ -1498,7 +1500,7 @@ jobs:
-e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \
install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress
docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \
"$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs > parity-evidence/comparator-tests.txt
"$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs > parity-evidence/comparator-tests.txt
docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \
--network=none --entrypoint node -v "$PWD:/source:ro" -w /source \
"$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt
Expand Down
4 changes: 3 additions & 1 deletion docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ These published PHP routes on this tree have no native handler yet, by family.
- **Other families.**
- Message streams.
- Durable workflow stream items and close.
- Service endpoints and the service catalog.
- Service endpoints. (The service catalog reads from Server 2.11 are served:
native never holds catalog rows, so every list is PHP's empty page and every
identity its not-found answer; the `service-catalog` fixture checks 37 cases.)
- Nexus operations.
- Deployments.
- Bridge adapters.
Expand Down
29 changes: 29 additions & 0 deletions rust/src/runtime/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ pub fn router(runtime: Runtime) -> Router {
.route("/api/system/activity-timeouts/pass", post(activity_timeout_pass))
.route("/api/workers", get(list_workers))
.route("/api/task-queues", get(list_task_queues))
.route("/api/service-catalog/{catalog}", get(service_catalog_list))
.route("/api/service-catalog/{catalog}/{id}", get(service_catalog_show))
.route("/api/task-queues/{task_queue}", get(describe_task_queue))
.route("/api/task-queues/{task_queue}/build-ids", get(task_queue_build_ids))
.route(
Expand Down Expand Up @@ -570,6 +572,10 @@ fn named_namespace_operation(method: &str, path: &str) -> bool {
if path.starts_with("/api/external-payloads/v1/") {
return method == "GET";
}
// The service catalog is namespace-scoped and, in native, always empty.
if path.starts_with("/api/service-catalog/") {
return method == "GET";
}
// Schedules are namespace-scoped rows, as in PHP.
if path == "/api/schedules" || path.starts_with("/api/schedules/") {
return true;
Expand Down Expand Up @@ -1858,6 +1864,29 @@ async fn fail_query(
) -> Result<Json<Value>> {
runtime.finish_query(&task_id, body, true).await.map(Json)
}
/// `GET /api/service-catalog/{catalog}`: endpoints, services, operations or calls.
async fn service_catalog_list(
Extension(runtime): Extension<Runtime>,
Path(catalog): Path<String>,
Query(query): Query<Vec<(String, String)>>,
) -> Response {
match super::service_catalog::Catalog::from_path(&catalog) {
Some(catalog) => super::service_catalog::list(&query, catalog, runtime.namespace()).map(Json).into_response(),
None => unavailable().await,
}
}

/// `GET /api/service-catalog/{catalog}/{id}`.
async fn service_catalog_show(
Path((catalog, _id)): Path<(String, String)>,
Query(query): Query<Vec<(String, String)>>,
) -> Response {
match super::service_catalog::Catalog::from_path(&catalog) {
Some(catalog) => super::service_catalog::show(&query, catalog).map(Json).into_response(),
None => unavailable().await,
}
}

async fn unavailable() -> Response {
refuse(
StatusCode::NOT_IMPLEMENTED,
Expand Down
10 changes: 10 additions & 0 deletions rust/src/runtime/laravel_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub(crate) enum Rule {
/// A size floor: a number, string length or item count, by type rule.
Min(i64),
Max(i64),
/// `between:min,max`: a size within both bounds, inclusive.
Between(i64, i64),
In(&'static [&'static str]),
/// `date`, for the ISO-8601 shapes `schedule_backfill::php_date` reads.
Date,
Expand Down Expand Up @@ -209,6 +211,11 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) -
"array" => format!("The {attribute} field must not have more than {limit} items."),
_ => format!("The {attribute} field must not be greater than {limit} characters."),
},
Rule::Between(min, max) => match kind {
"numeric" => format!("The {attribute} field must be between {min} and {max}."),
"array" => format!("The {attribute} field must have between {min} and {max} items."),
_ => format!("The {attribute} field must be between {min} and {max} characters."),
},
Rule::NotBlank | Rule::Pattern(_) => format!("The {attribute} field format is invalid."),
Rule::Date => format!("The {attribute} field must be a valid date."),
Rule::Nullable | Rule::Sometimes => String::new(),
Expand Down Expand Up @@ -274,6 +281,9 @@ fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bo
Rule::Max(limit) => value
.and_then(|value| size(value, rules))
.is_some_and(|size| size <= limit as f64),
Rule::Between(min, max) => value
.and_then(|value| size(value, rules))
.is_some_and(|size| size >= min as f64 && size <= max as f64),
}
}

Expand Down
1 change: 1 addition & 0 deletions rust/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ mod search_attribute_upserts;
mod search_attributes;
mod selection_summary;
mod selections;
mod service_catalog;
mod sqlite;
mod start_definition;
mod start_request;
Expand Down
195 changes: 195 additions & 0 deletions rust/src/runtime/service_catalog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//! Service catalog reads, as published Server 2.11 serves them
//! (`ServiceCatalogReadController`): endpoints, services, operations and
//! service calls, each listed by page or read by identity.
//!
//! Native never holds catalog rows. Takeover refuses a PHP database with
//! services, endpoints, operations or service calls, and native has no path
//! that writes them. Every list is therefore PHP's empty page and every
//! identity PHP's not-found answer, after PHP's validation of the filters.
use super::laravel_rules::{self, Rule};
use super::laravel_validation;
use super::{Result, RuntimeError};
use axum::http::StatusCode;
use serde_json::{Map, Value, json};

const DEFAULT_PAGE_SIZE: i64 = 50;
const SCOPES: &[&str] = &["relevant", "owned", "caller", "target"];
const STATUSES: &[&str] = &["pending", "accepted", "started", "completed", "failed", "cancelled"];
const OUTCOMES: &[&str] = &[
"accepted", "completed", "cancelled", "timed_out", "rejected_not_found", "rejected_forbidden",
"rejected_throttled", "rejected_concurrency_limited", "rejected_circuit_open", "degraded", "handler_failed",
];
const STATUS_BUCKETS: &[&str] = &["open", "completed", "failed", "cancelled"];
const OUTCOME_BUCKETS: &[&str] = &["open", "completed", "failed", "cancelled", "policy"];

const NAMESPACE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::Max(191)];
const PAGE: &[Rule] = &[Rule::Sometimes, Rule::Integer, Rule::Min(1)];
const PER_PAGE: &[Rule] = &[Rule::Sometimes, Rule::Integer, Rule::Between(1, 100)];
const IDENTITY: &[Rule] = &[Rule::Sometimes, Rule::Required, Rule::String, Rule::Max(191)];
const SCOPE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(SCOPES)];
const STATUS: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(STATUSES)];
const STATUS_BUCKET: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(STATUS_BUCKETS)];
const OUTCOME: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(OUTCOMES)];
const OUTCOME_BUCKET: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(OUTCOME_BUCKETS)];

/// The catalog a route reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Catalog {
Endpoints,
Services,
Operations,
Calls,
}

impl Catalog {
pub(crate) fn from_path(segment: &str) -> Option<Self> {
match segment {
"endpoints" => Some(Self::Endpoints),
"services" => Some(Self::Services),
"operations" => Some(Self::Operations),
"calls" => Some(Self::Calls),
_ => None,
}
}

/// The identity filters a list accepts, in PHP's order.
fn identities(self) -> &'static [&'static str] {
match self {
Self::Endpoints => &[],
Self::Services => &["endpoint_id"],
Self::Operations => &["endpoint_id", "service_id"],
Self::Calls => &["endpoint_id", "service_id", "operation_id"],
}
}
}

/// PHP's `filters`: unknown keys first, then each rule in its order, then
/// the bucket agreement. Returns the normalized query.
fn filters(query: &[(String, String)], catalog: Catalog, detail: bool) -> Result<Value> {
let mut rules: Vec<(&'static str, &'static [Rule])> = vec![("namespace", NAMESPACE)];
if !detail {
rules.push(("page", PAGE));
rules.push(("per_page", PER_PAGE));
rules.extend(catalog.identities().iter().map(|id| (*id, IDENTITY)));
if catalog == Catalog::Calls {
rules.extend([("scope", SCOPE), ("status", STATUS), ("status_bucket", STATUS_BUCKET),
("bucket", STATUS_BUCKET), ("outcome", OUTCOME), ("outcome_bucket", OUTCOME_BUCKET)]);
}
}
let mut unknown: Vec<(String, Vec<String>)> = Vec::new();
for (key, _) in query {
if !rules.iter().any(|(name, _)| name == key) && !unknown.iter().any(|(seen, _)| seen == key) {
unknown.push((key.clone(), vec!["Unsupported catalog filter.".into()]));
}
}
if let Some(error) = laravel_validation::failure_messages(unknown) {
return Err(error);
}
// A repeated key keeps its last value, as PHP's query parser does.
let mut body = Map::new();
for (key, value) in query {
body.insert(key.clone(), json!(value));
}
let body = laravel_rules::normalize(&Value::Object(body));
laravel_rules::validate(&body, &rules)?;
if let (Some(bucket), Some(status_bucket)) = (body["bucket"].as_str(), body["status_bucket"].as_str())
&& bucket != status_bucket
{
return Err(laravel_validation::failure(vec![("bucket", vec!["Must match status_bucket when both are supplied."])])
.expect("a message"));
}
Ok(body)
}

fn whole(value: &Value, default: i64) -> i64 {
value.as_str().and_then(|text| text.parse().ok()).unwrap_or(default)
}

/// A page of the catalog: always empty in native.
pub(crate) fn list(query: &[(String, String)], catalog: Catalog, namespace: &str) -> Result<Value> {
let filters = filters(query, catalog, false)?;
let mut page = json!({"data": [], "current_page": whole(&filters["page"], 1),
"per_page": whole(&filters["per_page"], DEFAULT_PAGE_SIZE), "total": 0, "last_page": 1, "namespace": namespace});
if catalog == Catalog::Calls {
let status_bucket = match &filters["status_bucket"] {
Value::Null => filters["bucket"].clone(),
bucket => bucket.clone(),
};
let buckets = |names: &[&str], members: &[&[&str]]| -> Value {
Value::Object(names.iter().zip(members).map(|(name, values)| ((*name).to_owned(), json!(values))).collect())
};
let extra = json!({
"scope": if filters["scope"].is_null() { json!("relevant") } else { filters["scope"].clone() },
"bucket": status_bucket, "status_bucket": status_bucket,
"outcome": filters["outcome"], "outcome_bucket": filters["outcome_bucket"],
"statuses": STATUSES, "outcomes": OUTCOMES,
"status_buckets": buckets(STATUS_BUCKETS, &[&["pending", "accepted", "started"], &["completed"], &["failed"], &["cancelled"]]),
"outcome_buckets": buckets(OUTCOME_BUCKETS, &[&["accepted"], &["completed", "degraded"], &["timed_out", "handler_failed"],
&["cancelled"], &["rejected_not_found", "rejected_forbidden", "rejected_throttled",
"rejected_concurrency_limited", "rejected_circuit_open"]]),
});
page.as_object_mut().expect("page").extend(extra.as_object().expect("extra").clone());
}
Ok(page)
}

/// One catalog object by identity: never present in native.
pub(crate) fn show(query: &[(String, String)], catalog: Catalog) -> Result<Value> {
filters(query, catalog, true)?;
Err(RuntimeError::Protocol {
status: StatusCode::NOT_FOUND,
response: json!({"message": "Catalog object not found.", "reason": "catalog_object_not_found"}),
})
}

#[cfg(test)]
mod tests {
use super::*;

fn query(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(key, value)| ((*key).to_owned(), (*value).to_owned())).collect()
}

fn refusal(result: Result<Value>) -> Value {
match result {
Err(RuntimeError::Protocol { status, response }) => json!([status.as_u16(), response]),
other => panic!("expected a refusal, got {other:?}"),
}
}

#[test]
fn lists_are_empty_pages_with_the_requested_paging() {
assert_eq!(list(&query(&[("page", "2"), ("per_page", "2")]), Catalog::Endpoints, "default").unwrap(),
json!({"data":[],"current_page":2,"per_page":2,"total":0,"last_page":1,"namespace":"default"}));
let calls = list(&query(&[("bucket", "open")]), Catalog::Calls, "second").unwrap();
assert_eq!([&calls["scope"], &calls["bucket"], &calls["status_bucket"], &calls["outcome"], &calls["namespace"]],
[&json!("relevant"), &json!("open"), &json!("open"), &Value::Null, &json!("second")]);
assert_eq!(calls["outcome_buckets"]["policy"].as_array().unwrap().len(), 5);
}

#[test]
fn filters_fail_like_laravel() {
assert_eq!(refusal(list(&query(&[("per_page", "0")]), Catalog::Endpoints, "default"))[1]["message"],
"The per page field must be between 1 and 100.");
assert_eq!(refusal(list(&query(&[("page", "x")]), Catalog::Endpoints, "default"))[1]["message"],
"The page field must be an integer.");
let unknown = refusal(list(&query(&[("bogus", "1"), ("other", "2")]), Catalog::Endpoints, "default"));
assert_eq!(unknown[1]["message"], "Unsupported catalog filter. (and 1 more error)");
assert_eq!(refusal(list(&query(&[("endpoint_id", "x")]), Catalog::Endpoints, "default"))[1]["errors"],
json!({"endpoint_id":["Unsupported catalog filter."]}));
assert_eq!(refusal(list(&query(&[("endpoint_id", "")]), Catalog::Services, "default"))[1]["message"],
"The endpoint id field is required.");
assert_eq!(refusal(list(&query(&[("bucket", "open"), ("status_bucket", "failed")]), Catalog::Calls, "default"))[1],
json!({"message":"Must match status_bucket when both are supplied.","reason":"validation_failed",
"errors":{"bucket":["Must match status_bucket when both are supplied."]},
"validation_errors":{"bucket":["Must match status_bucket when both are supplied."]}}));
assert_eq!(refusal(show(&query(&[("page", "2")]), Catalog::Endpoints))[1]["errors"],
json!({"page":["Unsupported catalog filter."]}));
}

#[test]
fn identities_are_not_found() {
assert_eq!(refusal(show(&query(&[("namespace", "default")]), Catalog::Calls)),
json!([404, {"message":"Catalog object not found.","reason":"catalog_object_not_found"}]));
}
}
Loading
Loading