netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
use crate::llm::actions::{
    client_trait::{Client, ClientActionResult},
    protocol_trait::Protocol,
    ActionDefinition, ParameterDefinition,
};
use crate::protocol::{ConnectContext, EventType};
use crate::server::scim::actions::{action, parameter};
use crate::server::scim::{model_bounds, query};
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

#[derive(Default)]
pub struct ScimClientProtocol;
impl ScimClientProtocol {
    pub fn new() -> Self {
        Self
    }
}

fn rt() -> crate::llm::actions::Parameter {
    parameter(
        "resource_type",
        "string",
        "Endpoint name of the resource type, e.g. Users or Groups",
        true,
    )
}
fn rid() -> crate::llm::actions::Parameter {
    parameter("id", "string", "The resource id", true)
}
fn list_action() -> ActionDefinition {
    action(
        "scim_list",
        "Query a resource type (GET with filter, sortBy, startIndex, count, attributes); Rust checks the filter syntax before sending",
        vec![
            rt(),
            parameter("filter", "string", "SCIM filter, e.g. userName eq \"bjensen\" or emails[type eq \"work\"]", false),
            parameter("sort_by", "string", "Attribute to sort on, e.g. userName", false),
            parameter("sort_order", "string", "ascending or descending", false),
            parameter("start_index", "number", "1-based index of the first result", false),
            parameter("count", "number", "Maximum results to return", false),
            parameter("attributes", "string", "Comma-separated attributes to return, e.g. userName,emails", false),
        ],
        json!({"type":"scim_list","resource_type":"Users","filter":"userName sw \"b\"","count":10}),
    )
}
fn get_action() -> ActionDefinition {
    action(
        "scim_get",
        "Read one resource",
        vec![rt(), rid()],
        json!({"type":"scim_get","resource_type":"Users","id":"2819c223"}),
    )
}
fn create_action() -> ActionDefinition {
    action(
        "scim_create",
        "Create a resource (POST); Rust adds the core schema URN when schemas is missing",
        vec![rt(), parameter("resource", "object", "The resource, e.g. {\"userName\": \"bjensen\", \"name\": {\"givenName\": \"Barbara\"}}", true)],
        json!({"type":"scim_create","resource_type":"Users","resource":{"userName":"bjensen","active":true}}),
    )
}
fn replace_action() -> ActionDefinition {
    action(
        "scim_replace",
        "Replace a resource (PUT)",
        vec![
            rt(),
            rid(),
            parameter("resource", "object", "The complete new resource", true),
        ],
        json!({"type":"scim_replace","resource_type":"Users","id":"2819c223","resource":{"userName":"bjensen","active":false}}),
    )
}
fn patch_action() -> ActionDefinition {
    action(
        "scim_patch",
        "Modify a resource with PatchOp operations; Rust checks each op and path before sending",
        vec![rt(), rid(), parameter("operations", "array", "[{op: add|remove|replace, path, value}], e.g. [{\"op\": \"replace\", \"path\": \"active\", \"value\": false}]", true)],
        json!({"type":"scim_patch","resource_type":"Users","id":"2819c223","operations":[{"op":"replace","path":"active","value":false}]}),
    )
}
fn delete_action() -> ActionDefinition {
    action(
        "scim_delete",
        "Delete a resource",
        vec![rt(), rid()],
        json!({"type":"scim_delete","resource_type":"Users","id":"2819c223"}),
    )
}
fn disconnect_action() -> ActionDefinition {
    action(
        "disconnect",
        "Stop this SCIM client",
        vec![],
        json!({"type":"disconnect"}),
    )
}
fn actions() -> Vec<ActionDefinition> {
    vec![
        list_action(),
        get_action(),
        create_action(),
        replace_action(),
        patch_action(),
        delete_action(),
        disconnect_action(),
    ]
}

pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new("scim_connected", "The service's ServiceProviderConfig and ResourceTypes were read", list_action().example.clone())
        .with_parameters(vec![
            parameter("resource_types", "array", "[{name, endpoint, schema}] the service offers", true),
            parameter("features", "object", "What ServiceProviderConfig says is supported: {patch, filter, sort, bulk, etag, changePassword}", true),
        ])
        .with_actions(actions())
});
pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "scim_response",
        "The service's answer to the last operation",
        disconnect_action().example.clone(),
    )
    .with_parameters(vec![
        parameter(
            "operation",
            "string",
            "list, get, create, replace, patch or delete",
            true,
        ),
        parameter(
            "status",
            "number",
            "HTTP status of the answer: 200, 201 created, 204 no content, or the error status",
            true,
        ),
        parameter(
            "resource",
            "object",
            "The resource returned by get, create, replace or patch",
            false,
        ),
        parameter("total_results", "number", "For list: totalResults", false),
        parameter(
            "resources",
            "array",
            "For list: the page of resources",
            false,
        ),
        parameter(
            "error",
            "object",
            "SCIM error: {status, scim_type, detail}",
            false,
        ),
    ])
    .with_actions(actions())
});

impl Protocol for ScimClientProtocol {
    fn protocol_name(&self) -> &'static str {
        "SCIM"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>HTTP>SCIM"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec!["scim", "scim2", "provisioning client"]
    }
    fn description(&self) -> &'static str {
        "SCIM 2.0 client: discovers a service, then lists, reads, creates, replaces, patches and deletes Users and Groups"
    }
    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
        actions()
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![]
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
    }
    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        let p =
            |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
                ParameterDefinition {
                    name: name.into(),
                    type_hint: kind.into(),
                    description: description.into(),
                    required: false,
                    example,
                    default,
                }
            };
        vec![
            p(
                "scheme",
                "string",
                "URL scheme to reach the service with: https (default) or http",
                json!("http"),
                Some(json!(super::DEFAULT_SCHEME)),
            ),
            p(
                "base_path",
                "string",
                "Path the service's SCIM endpoints live under (empty for the root)",
                json!("/scim/v2"),
                Some(json!(crate::server::scim::DEFAULT_BASE_PATH)),
            ),
            p(
                "bearer_token",
                "string",
                "Token sent as Authorization: Bearer",
                json!("s3cr3t"),
                None,
            ),
        ]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .implementation("Shared http_fetch client (reqwest natively, no redirects); application/scim+json; ServiceProviderConfig and ResourceTypes discovery; filters and PATCH paths checked with the server's own parser; ListResponse and SCIM errors parsed")
            .llm_control("Which resources to query, create, change or delete, and what to do with each answer")
            .e2e_testing("tests/client/scim: scim2-server 0.4.0 (independent) answers discovery, create, filtered and sorted lists, get, PATCH, PUT, a uniqueness conflict and delete")
            .notes("No Bulk or /Me; one request at a time; 1 MiB answers.")
            .max_inbound_bytes(model_bounds::MAX_BODY_BYTES)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "Provision user bjensen into the SCIM service at 127.0.0.1:8080 and add her to Engineering"
    }
    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        let llm = json!({"type":"open_client","protocol":"scim","remote_addr":"127.0.0.1:8080","instruction":"List all users","startup_params":{"scheme":"http"}});
        let mut static_example = llm.clone();
        static_example["event_handlers"] = json!([
            {"event_pattern":"scim_connected","handler":{"type":"static","actions":[list_action().example]}},
            {"event_pattern":"scim_response","handler":{"type":"static","actions":[]}}
        ]);
        let mut scripted = static_example.clone();
        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "Authentication"
    }
}

impl Client for ScimClientProtocol {
    fn connect(
        &self,
        ctx: ConnectContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
    {
        Box::pin(super::connect(ctx))
    }
    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
        let ty = v["type"].as_str().unwrap_or_default();
        if ty == "disconnect" {
            return Ok(ClientActionResult::Disconnect);
        }
        ensure!(
            matches!(
                ty,
                "scim_list"
                    | "scim_get"
                    | "scim_create"
                    | "scim_replace"
                    | "scim_patch"
                    | "scim_delete"
            ),
            "Unknown SCIM client action"
        );
        let rt = v["resource_type"]
            .as_str()
            .context("resource_type is required")?;
        ensure!(
            !rt.is_empty() && rt.len() <= 64 && rt.bytes().all(|b| b.is_ascii_alphanumeric()),
            "resource_type is an endpoint name like Users"
        );
        ensure!(
            model_bounds::budget_ok(&v),
            "action exceeds the SCIM bounds"
        );
        if matches!(
            ty,
            "scim_get" | "scim_replace" | "scim_patch" | "scim_delete"
        ) {
            ensure!(
                v["id"]
                    .as_str()
                    .is_some_and(|i| !i.is_empty() && i.len() <= 256 && !i.contains('/')),
                "id is required"
            );
        }
        match ty {
            "scim_list" => {
                if let Some(f) = v["filter"].as_str() {
                    query::parse_filter(f)?;
                }
                if let Some(s) = v["sort_by"].as_str() {
                    query::AttrPath::parse(s)?;
                }
                query::parse_list(v["attributes"].as_str())?;
                if let Some(o) = v["sort_order"].as_str() {
                    ensure!(
                        o == "ascending" || o == "descending",
                        "sort_order is ascending or descending"
                    );
                }
            }
            "scim_create" | "scim_replace" => {
                ensure!(v["resource"].is_object(), "resource must be an object")
            }
            "scim_patch" => {
                let ops = v["operations"]
                    .as_array()
                    .filter(|o| !o.is_empty() && o.len() <= model_bounds::MAX_PATCH_OPERATIONS)
                    .context("operations must hold 1..64 operations")?;
                for o in ops {
                    let op = o["op"]
                        .as_str()
                        .map(str::to_ascii_lowercase)
                        .context("each operation needs op")?;
                    ensure!(
                        matches!(op.as_str(), "add" | "remove" | "replace"),
                        "op is add, remove or replace"
                    );
                    if let Some(p) = o["path"].as_str() {
                        query::PatchPath::parse(p)?;
                    } else if op == "remove" {
                        bail!("remove needs a path");
                    }
                }
            }
            _ => {}
        }
        Ok(ClientActionResult::Custom {
            name: ty.into(),
            data: v,
        })
    }
}