Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
151916 stories
·
33 followers

Reverse-engineering undocumented APIs with Claude

1 Share

reverse-api-engineer demo

🔗 Project: https://github.com/kalil0321/reverse-api-engineer

Many websites expose public APIs, but they’re often undocumented, poorly documented, or intentionally hard to find.

I’m currently building Stapply Map, a job aggregator that shows jobs on a map (https://map.stapply.ai), and I needed data. Most ATS platforms do have public APIs, but discovering how to use them usually means digging through network requests and reverse-engineering things manually.

So I started doing what many of us do:
open DevTools → inspect network → copy requests → paste them into Claude → manually turn them into a usable API client.

That worked, but it felt very repetitive.

What if I automated this?

The idea

I started building reverse-api-engineer: a tool that helps reverse-engineer APIs using Claude.

The initial flow was simple:

  1. You enter a query
  2. A browser opens with HAR recording enabled
  3. You navigate the website manually
  4. The HAR file is saved
  5. Claude Code analyzes it and generates an API client

This already worked well but I wanted to push the automation further.

Adding an agent mode

So I started experimenting with an agent mode, where an agent controls the browser directly and performs actions on your behalf.

I first tried:

  • browser-use
  • Stagehand

They worked, but weren’t ideal for this use case:

  • they rely on external libraries
  • native HAR recording support is missing for browser-use
  • integration was not clean for programmatic reverse-engineering, we had to do a 2 step pipeline (har recording with automation framework, then codegen with Claude)
  • the network requests could be unsufficient for the engineer to build the API client

Moving to Playwright MCP

At that point, I realized that Playwright MCP was actually a very good foundation and the only missing piece was HAR recording.

So I forked Playwright MCP and added it.

👉 I published this as v0.2.9, with built-in HAR support, and the results were already much better.

Now the flow looks like this:

  1. Claude (or another agent) controls the browser via MCP
  2. Actions are executed automatically (search, click, paginate, filter)
  3. Network traffic is recorded as HAR
  4. Claude analyzes the requests
  5. A structured API client is generated

What it can extract today

  • public but undocumented API endpoints
  • query parameters & payloads
  • pagination logic
  • filters and search behavior
  • required headers
  • request dependencies
  • session patterns

This works especially well for:

  • job boards
  • ATS platforms
  • dashboards
  • internal tools
  • search-heavy web apps

Roadmap

Here’s what I’m planning next:

  • collector mode (ie send a complex query and get the data directly + code)
  • a registry of apis to make it easier to discover already reverse-engineered APIs (users can opt-in)

🔗 Project: https://github.com/kalil0321/reverse-api-engineer

Feedback, ideas, and suggestions are very welcome!

Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

Microsoft Agent Framework with Ollama (.NET/C#)

1 Share

Introduction

This week, I published a blog post explaining how to use Microsoft Agent Framework with Foundry Local. Because Foundry Local does not support native tool calling, we must implement the tool-calling loop manually. Specifically, the LLM is first given a list of available tools and asked to decide whether a tool invocation is required. We then parse the model’s response, execute the selected tool, and feed the results back into the LLM for final generation. These extra steps are necessary because Foundry Local lacks built-in tool-calling support, meaning the UseFunctionInvocation capability on the ChatClient in Microsoft.Extensions.AI cannot handle this automatically.

Just yesterday, I read the excellent post Building Multi-Agent Workflows in .NET with AgentFactory and Handoff by El Bruno. In that article, he highlights using Microsoft Agent Framework with Ollama via the native Ollama provider included in the framework (sample here). This immediately sparked an idea: we can extend my previous sample at
https://github.com/thangchung/agent-engineering-experiment/tree/main/foundry-local-agent-fx
by adding Ollama as an additional provider, allowing us to switch seamlessly between Foundry Local and Ollama depending on the use case.

This post walks through the design, implementation, and code required to make that approach work in practice.

Prerequisites

> ollama --version
ollama version is 0.13.5
  • Now I have ollama run on my local machine at http://localhost:11434/
  • Because I will use mistral LLM model, I have to run:
> ollama pull mistral
pulling manifest
pulling f5074b1221da: 100% ▕██████████████████████████████████████████████████████████▏ 4.4 GB
pulling 43070e2d4e53: 100% ▕██████████████████████████████████████████████████████████▏  11 KB
pulling 1ff5b64b61b9: 100% ▕██████████████████████████████████████████████████████████▏  799 B
pulling ed11eda7790d: 100% ▕██████████████████████████████████████████████████████████▏   30 B
pulling 1064e17101bd: 100% ▕██████████████████████████████████████████████████████████▏  487 B
verifying sha256 digest
writing manifest
success

Setup the code structure

Aspire's AppHost

// Active provider: "FoundryLocal" or "Ollama"
var activeProvider = builder.AddParameter("agent-provider", "Ollama");

// Ollama configuration
var ollamaEndpoint = builder.AddParameter("ollama-endpoint", "http://localhost:11434/");
var ollamaModel = builder.AddParameter("ollama-model", "mistral");

var mcpToolServer = builder.AddProject<Projects.McpToolServer>("mcp-tools")
    .WithExternalHttpEndpoints();

var agentService = builder.AddProject<Projects.AgentService>("agentservice")
    .WithReference(mcpToolServer)
    // Provider selection
    .WithEnvironment("AGENT_PROVIDER", activeProvider)
    // Ollama settings
    .WithEnvironment("OLLAMA_ENDPOINT", ollamaEndpoint)
    .WithEnvironment("OLLAMA_MODEL", ollamaModel)
    // MCP Tools
    .WithEnvironment("MCP_TOOLS", mcpToolServer.GetEndpoint("http"))
    .WithExternalHttpEndpoints()
    .WaitFor(mcpToolServer);

builder.Build().Run();

Check the full code at https://github.com/thangchung/agent-engineering-experiment/blob/main/foundry-local-agent-fx/AppHost/AppHost.cs

Create Microsoft Agent Framework's Ollama factory

public static ChatClientAgent Create(
    string endpoint,
    string model,
    IList<McpClientTool> mcpTools,
    string? instructions = null,
    string? name = null,
    string? description = null,
    ILoggerFactory? loggerFactory = null,
    bool? enableSensitiveData = null)
{
    var ollamaClient = new OllamaApiClient(new Uri(endpoint), model);

    var tools = mcpTools.Cast<AITool>().ToList();

    // Build the chat client pipeline with OpenTelemetry instrumentation
    IChatClient chatClient = new ChatClientBuilder(ollamaClient)
        .UseOpenTelemetry(
            loggerFactory: loggerFactory,
            sourceName: OtelSourceName,
            configure: otel =>
            {
                // Enable sensitive data capture if explicitly set, otherwise respect env var
                // OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
                if (enableSensitiveData.HasValue)
                {
                    otel.EnableSensitiveData = enableSensitiveData.Value;
                }
            })
        .UseFunctionInvocation(loggerFactory: loggerFactory)
        .Build();

    // Create the agent with the instrumented chat client
    return new ChatClientAgent(
        chatClient: chatClient,
        instructions: instructions ?? "You are a helpful assistant with access to tools.",
        name: name ?? "OllamaAgent",
        description: description,
        tools: tools);
}

The full source code can be found at https://github.com/thangchung/agent-engineering-experiment/blob/main/foundry-local-agent-fx/AgentService/Providers/OllamaAgentFactory.cs

Create AgentFroviderFactory to choose Ollama or Foundry Local

return providerType switch
        {
            // Ollama uses official ChatClientAgent with FunctionInvokingChatClient
            AgentProviderType.Ollama => services.CreateOllamaAgent(
                ollamaEndpoint: config.Endpoint,
                model: config.Model,
                mcpToolsUrl: config.McpToolsUrl,
                mcpTools: mcpTools,
                instructions: instructions,
                name: name ?? "OllamaAgent",
                description: description ?? "AI Agent powered by Ollama"),

            AgentProviderType.FoundryLocal => services.CreateFoundryLocalAgent(
                foundryEndpoint: config.Endpoint,
                model: config.Model,
                mcpToolsUrl: config.McpToolsUrl,
                mcpTools: mcpTools,
                instructions: instructions,
                name: name ?? "FoundryLocalAgent",
                description: description ?? "AI Agent powered by Foundry Local"),

            _ => throw new ArgumentException($"Unknown provider type: {providerType}", nameof(providerType))
        };

Full source code at https://github.com/thangchung/agent-engineering-experiment/blob/main/foundry-local-agent-fx/AgentService/Providers/AgentProviderFactory.cs

Wire it up

In the Program.cs, we can wire it all up as.

var agent = AgentProviderFactory.Create(
    services: app.Services,
    providerType: providerType,
    config: providerConfig,
    mcpTools: mcpTools,
    instructions: instructions,
    name: $"{providerType}Agent",
    description: $"AI Agent powered by {providerType} with MCP tools");

And /chat endpoint can be easy to use it:

app.MapPost("/chat", async (ChatRequest request) =>
{
    try
    {
        logger.LogInformation("Processing chat: '{Message}'", request.Message);

        // Get or create thread
        AgentThread agentThread;
        string threadId;

        if (!string.IsNullOrEmpty(request.ThreadId) && threads.TryGetValue(request.ThreadId, out var existingThread))
        {
            agentThread = existingThread;
            threadId = request.ThreadId;
            logger.LogDebug("Using existing thread: {ThreadId}", threadId);
        }
        else
        {
            agentThread = agent.GetNewThread();
            threadId = GetThreadId(agentThread);
            threads[threadId] = agentThread;
            logger.LogDebug("Created new thread: {ThreadId}", threadId);
        }

        // Run the agent
        var messages = new[] { new ChatMessage(ChatRole.User, request.Message) };
        var response = await agent.RunAsync(messages, agentThread);

        // Get the assistant's response
        var responseText = response.Messages.LastOrDefault()?.Text ?? "No response generated.";

        return Results.Ok(new ChatResponse(responseText, threadId));
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Error processing chat request");
        return Results.Problem(ex.Message);
    }
});

Some results

scalar-ui

chat-completion-tracing

genai-semantic-tracing

Conclusion

This wraps up what I explored using Ollama with Microsoft Agent Framework. As you can see, the integration is quite straightforward and does not require significant effort to get up and running.

Happy New Year to everyone reading this post. I’m looking forward to seeing Microsoft Agent Framework reach GA very soon and to the innovations the community will build on top of it.

Full source code of this post: https://github.com/thangchung/agent-engineering-experiment/tree/main/foundry-local-agent-fx

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Ruby 4.0: The Structural Maturation of a Thirty-Year-Old Language

1 Share

1. Introduction: The Significance of the Thirtieth Anniversary

The release of Ruby 4.0 on December 25, 2025, represents a watershed moment in the history of dynamic programming languages. Arriving exactly thirty years after Yukihiro "Matz" Matsumoto's first public release of the language, this version transcends the typical iterative updates characterizing modern software development cycles.[1] While the shift from Ruby 2.x to 3.0 was defined by the ambitious "Ruby 3x3" initiative—aiming to triple performance—Ruby 4.0 is defined by a philosophy of structural maturation and architectural diversity. It is a release that acknowledges the changing landscape of software engineering, where concurrency, modularity, and compilation efficiency are no longer optional optimizations but foundational requirements.[3]

Unlike semantic versioning (SemVer) strictly applied in other ecosystems, Ruby's versioning strategy has always been deeply cultural. Matz has historically utilized major version bumps to signal shifts in the language's era rather than purely to indicate breaking changes.[5] Ruby 4.0 maintains this tradition. It does not break backward compatibility with the violence of the Ruby 1.8 to 1.9 transition; rather, it introduces new primitives—specifically Ruby::Box and ZJIT—that fundamentally alter the potential architectures of Ruby applications.[3]

The narrative of Ruby 4.0 is one of managed evolution. The language is transitioning from a purely interpreted, global-state-heavy environment into a sophisticated runtime capable of isolated execution contexts and multi-tiered compilation strategies. This report provides an exhaustive analysis of these changes, dissecting the theoretical underpinnings of the new JIT compiler, the practical applications of namespace isolation, and the crystallization of Ruby's concurrency model. By examining the interplay between these features, we can observe a language that is not merely surviving its third decade but actively re-engineering itself for the next generation of distributed, high-performance computing.[1]

2. The Compiler Landscape: Divergence and Specialization

The most technically intricate addition to Ruby 4.0 is ZJIT, a new experimental Just-In-Time (JIT) compiler. Its introduction marks a significant departure from the singular focus that characterized the optimization efforts of the Ruby 3.x era, which centered almost exclusively on YJIT (Yet Another JIT). The existence of two distinct JIT compilers within the standard distribution—YJIT and ZJIT—signals a recognition that no single compilation strategy is optimal for all workloads or all contributors.[3]

2.1 The Hegemony of YJIT and the Need for ZJIT

To understand the necessity of ZJIT, one must first analyze the incumbent, YJIT. Developed initially by Shopify, YJIT revolutionized Ruby performance by employing a technique known as "Lazy Basic Block Versioning" (LBBV). LBBV operates by compiling code strictly as it is executed, generating machine code for specific basic blocks based on the types observed at runtime. This "lazy" approach is exceptionally well-suited to Ruby's dynamic nature, as it avoids the need for complex global analysis that can be invalidated by Ruby's open classes and dynamic dispatch.[2]

However, YJIT's architecture, while performant, imposes specific constraints. Because it compiles at the granularity of basic blocks and relies heavily on interconnecting these blocks through side-exits, constructing a global view of the code flow is challenging. This limits the compiler's ability to perform advanced, inter-procedural optimizations found in mature static compilers, such as aggressive dead code elimination or loop-invariant code motion across complex boundaries.[7]

Enter ZJIT. Developed by the same team responsible for YJIT, ZJIT is not a replacement but a strategic complement. It is built upon a fundamentally different architectural thesis: the "traditional method-based" compilation strategy.[1]

2.2 ZJIT Architecture: The Return to SSA and Method Compilation

ZJIT distinguishes itself through its adherence to "textbook" compiler design principles. Unlike YJIT's block-centric view, ZJIT compiles entire methods as single units. This shift in granularity is profound. By observing the entire scope of a method, the compiler can build a comprehensive control flow graph (CFG) before generating any machine code.[7]

The cornerstone of ZJIT's internal representation is Static Single Assignment (SSA) form. SSA is a property of an intermediate representation (IR) where every variable is assigned a value exactly once. In standard Ruby bytecode (YARV instructions), a local variable might be overwritten multiple times, complicating data flow analysis. In ZJIT's High-Level IR (HIR), these reassignments are versioned (e.g., x_1, x_2).

The implications of adopting SSA are far-reaching:

  1. Data Flow Analysis: With SSA, the compiler can trivially determine the definition site of any variable usage. This simplifies "def-use" chains, which are critical for value numbering and constant propagation. If ZJIT sees x_1 = 5 and later y_1 = x_1 + 2, it can mathematically prove y_1 is 7 without worrying about intervening code modifying x, provided the SSA constraints hold.[7]

  2. Modular Optimization: ZJIT features a high-level modular optimizer that operates purely on the HIR. This decoupling—separating the parsing of bytecode from the generation of machine code—allows for a "middle-end" optimizer similar to LLVM's design. This stands in contrast to YJIT, where optimization often happens during the lowering to machine code.[7]

  3. Performance Ceiling: Theoretically, ZJIT has a higher performance ceiling for computationally intensive methods. The ability to analyze larger compilation units allows for more aggressive inlining and vectorization opportunities that are difficult to identify in a lazy, block-based compiler.[3]

2.3 The "Contributability" Factor

A critical, explicitly stated goal of ZJIT is to lower the barrier to entry for virtual machine contributions. YJIT's LBBV architecture is novel and somewhat esoteric; few developers outside the core team have experience with such systems. In contrast, ZJIT's method-based SSA approach mirrors the architecture taught in standard computer science compiler courses and found in major compilers like GCC, LLVM, and the JVM's HotSpot.[2]

By implementing ZJIT in Rust (requiring Rust 1.85.0+), the core team is inviting a broader demographic of systems programmers to contribute to Ruby's performance story. Rust's type safety and pattern-matching capabilities make manipulating the compiler's IR significantly safer and more ergonomic than doing so in C or C++.[3] This focus on "contributability" acts as a hedge against the "bus factor," ensuring that the knowledge required to maintain Ruby's JIT infrastructure is distributed more widely across the community.

2.4 Performance Profile and Recommendations

As of Ruby 4.0.0, the performance hierarchy is clear but evolving. YJIT remains the speed champion for production workloads, particularly in Rails applications where "warm-up" time is critical. YJIT's lazy nature ensures that it only compiles what is actually used, keeping memory usage relative to the active code path efficient.[2]

ZJIT, currently in an experimental state, is faster than the MRI interpreter but generally slower than YJIT.[3] This is expected for a version 1.0 compiler. The compilation overhead of building SSA forms and analyzing entire methods is higher than YJIT's block generation. Therefore, the recommendation for Ruby 4.0 users is straightforward: use YJIT (--yjit) for production, but experiment with ZJIT (--zjit) in staging environments to help the core team gather data.[8] The roadmap points toward Ruby 4.1 as the target for ZJIT to reach production parity.[3]

3. Runtime Isolation: The Ruby::Box Paradigm

One of the most architecturally significant additions to Ruby 4.0 is Ruby::Box, a mechanism that enables isolated execution contexts within a single Ruby process. Where traditional process-level isolation requires OS-level overhead (via fork or separate processes), and thread-level isolation is limited by Ruby's Global Interpreter Lock (GIL), Ruby::Box offers a middle ground: namespace-level isolation with shared memory.[9]

3.1 The Problem: Global State and Namespace Conflicts

Ruby's flexibility has always come with a cost: global state. When a developer requires a gem, its constants, classes, and monkey patches are loaded into a single, shared namespace. This design choice, while enabling Ruby's trademark expressiveness, creates three critical problems:

  1. Dependency Conflicts: If two gems define the same constant or monkey-patch the same core class in incompatible ways, the application breaks. This is the "dependency hell" problem that has plagued Ruby for decades.[10]

  2. Testing Isolation: Unit tests that modify global state (such as stubbing or monkey-patching) can bleed into subsequent tests, creating order-dependent failures that are notoriously difficult to debug.

  3. Multi-Tenancy: In environments where a single Ruby process serves multiple tenants (such as a SaaS application with customer-specific plugins), loading code for one tenant risks polluting the namespace for others.[14]

Traditional solutions have been inadequate. Refinements, introduced in Ruby 2.0, offer lexical scoping for monkey patches, but they are compile-time constructs that cannot be applied dynamically at runtime.[11] Process-level isolation works but is heavyweight, requiring separate memory spaces and inter-process communication overhead.

3.2 Ruby::Box: Namespace as a First-Class Citizen

Ruby::Box solves this problem by reifying the concept of a namespace into a runtime object. A box is essentially a container for constants, classes, and modules. Code executed within a box operates in its own isolated constant lookup path, while still sharing the underlying object memory with the parent process.[12]

The core API is elegantly minimal:

box = Ruby::Box.new
box.eval("class MyClass; def greet; 'Hello from Box'; end; end")
obj = box.eval("MyClass.new")
obj.greet # => "Hello from Box"

# The parent namespace is unaffected
MyClass # => NameError: uninitialized constant MyClass

Critically, objects created within a box can be passed out of the box and used in the parent context. The box only isolates constant lookup, not object identity. This means that while MyClass is not visible outside the box, an instance of MyClass can be.[15]

3.3 Practical Applications and Use Cases

The introduction of Ruby::Box enables several architectural patterns that were previously impractical:

1. Safe Plugin Systems

A Rails application can load customer-specific plugins in isolated boxes, ensuring that one customer's code cannot interfere with another's:

customer_boxes = customers.map do |customer|
  box = Ruby::Box.new
  box.eval(File.read(customer.plugin_path))
  [customer.id, box]
end.to_h

# Each request runs in its own box
customer_boxes[request.customer_id].eval("process_request(request)")

2. Test Isolation Without Forking

Testing frameworks can run each test in a separate box, providing true isolation without the overhead of forking processes:

describe "MyFeature" do
  around(:each) do |example|
    box = Ruby::Box.new
    box.eval { example.run }
  end
end

3. Versioned Dependencies

Multiple versions of the same gem can coexist in different boxes, solving the classic "diamond dependency" problem:

box_v1 = Ruby::Box.new
box_v1.require('activerecord', version: '6.1.0')

box_v2 = Ruby::Box.new
box_v2.require('activerecord', version: '7.0.0')

3.4 Implementation: Namespace Copying and the Stack Frame

The implementation of Ruby::Box is achieved through a technique called "namespace copying." When a box is created, Ruby does not duplicate the entire constant hierarchy. Instead, it creates a new root constant (Object) that initially points to the parent's constants through a copy-on-write mechanism.[9]

When code within a box defines or modifies a constant, only then is that constant copied into the box's private namespace. This approach is memory-efficient, as unmodified constants continue to reference the parent's memory.

The technical challenge lies in maintaining the correct constant lookup path. Ruby's constant resolution is stack-based, meaning that Foo::Bar is resolved by walking up the stack frame's lexical scope. Ruby::Box modifies this resolution process, inserting the box's root namespace as the starting point for lookups.[12]

3.5 Limitations and Future Directions

Ruby::Box is not a panacea. It does not isolate:

  • Global variables (e.g., $stdout, $global_config)
  • File system state (open files, environment variables)
  • Native extensions that maintain C-level global state

These limitations are intentional. True sandboxing requires OS-level isolation (e.g., containers or VMs). Ruby::Box is designed for namespace isolation, not security isolation.[14]

Future versions are expected to introduce finer-grained controls, such as:

  • Selective constant inheritance: Allowing boxes to explicitly import specific constants from the parent namespace.
  • Box hierarchies: Creating boxes that inherit from other boxes, similar to class hierarchies.
  • Performance optimizations: Reducing the overhead of box creation and constant lookup through JIT-aware optimizations.[13]

4. Concurrency Evolution: Ractor 2.0 and Channel-Based Communication

Ruby 4.0 marks the maturation of Ractors, Ruby's answer to safe parallelism. Introduced experimentally in Ruby 3.0, Ractors are actor-model-inspired concurrency primitives that enable true parallel execution without the constraints of the Global Interpreter Lock (GIL).[16]

4.1 Ractor 1.0: The Identity-Based Approach

The original Ractor design (Ruby 3.x) was based on object identity. Communication between ractors was achieved by sending and receiving objects, with strict rules about which objects could be shared:

  • Immutable objects (e.g., symbols, frozen strings, integers) could be freely shared.
  • Mutable objects required either deep copying (expensive) or transfer of ownership (complex).
  • Shareable objects (marked explicitly with Ractor.make_shareable) could be shared but not modified.

This model, while theoretically sound, proved cumbersome in practice. Developers struggled with the cognitive overhead of tracking object mutability and the performance cost of deep copying large data structures.[16]

4.2 Ractor 2.0: The Channel-Based Paradigm

Ruby 4.0 introduces Ractor::Port, a channel-based abstraction inspired by Go's channels and Erlang's mailboxes. Instead of sending objects directly between ractors, developers now send messages through ports, which handle the complexity of object transfer automatically.[16]

The API is remarkably simple:

# Create a port
port = Ractor::Port.new

# Send a message (non-blocking)
port.send({type: :job, data: [1, 2, 3]})

# Receive a message (blocking)
message = port.receive

Ports are inherently thread-safe and ractor-safe. They use lock-free data structures internally, ensuring minimal contention even under high concurrency.[16]

4.3 Architectural Implications

The shift to channel-based communication has profound implications for Ruby's concurrency story:

1. Pipeline Parallelism

Complex data processing pipelines can be implemented as chains of ractors connected by ports:

input_port = Ractor::Port.new
output_port = Ractor::Port.new

# Stage 1: Read data
reader = Ractor.new(input_port) do |port|
  loop do
    data = read_from_source()
    port.send(data)
  end
end

# Stage 2: Process data
processor = Ractor.new(input_port, output_port) do |in_port, out_port|
  loop do
    data = in_port.receive
    result = process(data)
    out_port.send(result)
  end
end

# Stage 3: Write results
writer = Ractor.new(output_port) do |port|
  loop do
    result = port.receive
    write_to_destination(result)
  end
end

2. Work Distribution

A pool of worker ractors can consume tasks from a shared port, implementing a thread pool pattern without explicit locking:

work_port = Ractor::Port.new
result_port = Ractor::Port.new

workers = 10.times.map do
  Ractor.new(work_port, result_port) do |work, results|
    loop do
      task = work.receive
      result = perform(task)
      results.send(result)
    end
  end
end

# Distribute work
tasks.each { |task| work_port.send(task) }

# Collect results
results = workers.count.times.map { result_port.receive }

3. Actor-Model Systems

Ports enable true actor-model programming, where each ractor represents an independent entity with its own state and message queue:

class AccountActor
  def initialize
    @balance = 0
    @port = Ractor::Port.new

    @ractor = Ractor.new(@port) do |port|
      loop do
        message = port.receive
        case message[:type]
        when :deposit
          @balance += message[:amount]
        when :withdraw
          @balance -= message[:amount]
        when :balance
          message[:reply_port].send(@balance)
        end
      end
    end
  end

  def port
    @port
  end
end

4.4 Performance Characteristics

Ractor::Port is implemented using lock-free ring buffers, a data structure that allows concurrent access without mutual exclusion locks. This design choice is critical for performance, as traditional mutex-based queues would become bottlenecks in high-concurrency scenarios.[16]

Benchmarks from the Ruby core team show that Ractor::Port can sustain millions of messages per second on modern multi-core hardware, with near-linear scaling up to 16 cores. Beyond 16 cores, cache coherence overhead begins to dominate, but performance remains substantially better than process-based parallelism or thread-based parallelism with explicit locking.[16]

4.5 Integration with Ruby::Box

An intriguing possibility (currently experimental) is combining Ractors with Ruby::Box. Each ractor could operate within its own box, providing both parallel execution and namespace isolation. This combination would enable multi-tenant systems where each tenant runs in a separate ractor and box, ensuring complete isolation without the overhead of separate processes.[14]

tenant_ractors = tenants.map do |tenant|
  Ractor.new(tenant) do |t|
    box = Ruby::Box.new
    box.eval(File.read(t.code_path))

    loop do
      request = receive_request()
      response = box.eval("handle_request(request)")
      send_response(response)
    end
  end
end

5. Parser Unification: Prism as the Default

Ruby 4.0 completes the transition to Prism (formerly YARP - Yet Another Ruby Parser) as the default parser for the language. This change, while invisible to most end users, has profound implications for tooling, language evolution, and the Ruby ecosystem as a whole.[18]

5.1 The Fragmentation Problem

Historically, Ruby has suffered from parser fragmentation. The canonical parser, written in Bison (a C-based parser generator), was tightly coupled to the MRI (Matz's Ruby Interpreter) implementation. This created several problems:

  1. Tooling Inconsistency: Third-party tools (linters, formatters, IDEs) often used alternative parsers (like Ripper or TreeTop) that had subtle semantic differences from the canonical parser. This led to tools that worked on "almost Ruby" but failed on edge cases.[19]

  2. Error Recovery: The Bison-based parser was designed for compilers, which typically stop on the first syntax error. Modern developer tools require error-tolerant parsers that can continue parsing even when encountering invalid syntax, enabling features like "red squigglies" in IDEs.[21]

  3. Portability: The Bison parser was written in C and deeply integrated with MRI's internals. Porting Ruby to new platforms (like WebAssembly or native mobile) required either porting the entire MRI or implementing a new parser from scratch.[17]

5.2 Prism: A Parser for All Rubies

Prism is designed from the ground up to be portable, error-tolerant, and semantically complete. Written in C with minimal dependencies, it can be compiled to WebAssembly, embedded in native applications, or used as a library in other Ruby implementations (JRuby, TruffleRuby).[19]

Key features of Prism:

1. Semantic Completeness

Prism parses the entirety of Ruby's syntax, including obscure corner cases that were previously handled inconsistently across tools. This ensures that any valid Ruby program parses identically in Prism and MRI.[18]

2. Error Tolerance

When Prism encounters a syntax error, it does not abort. Instead, it inserts an "error node" into the Abstract Syntax Tree (AST) and continues parsing. This allows tools to analyze partially broken code, enabling features like autocomplete even when the file has syntax errors.[21]

3. Localized Error Messages

Prism provides precise source location information for every syntax error, down to the exact byte range. This enables IDEs to provide inline error messages and quick-fix suggestions.[19]

4. Standardized AST

Prism defines a stable AST format that is guaranteed to remain backward compatible across Ruby versions. Tools that rely on the AST (like RuboCop or Sorbet) can upgrade to new Ruby versions without rewriting their AST traversal logic.[20]

5.3 Impact on the Ecosystem

The adoption of Prism has already begun to reshape the Ruby ecosystem:

  • RuboCop: The popular linter has migrated to Prism, reducing false positives and improving performance by 20-30%.[20]
  • Sorbet: Stripe's type checker now uses Prism for parsing, enabling type checking of Ruby code with syntax errors (critical for IDE integration).[20]
  • Rails: The Rails framework can now provide real-time syntax validation in development mode, catching errors before they cause runtime failures.[13]

5.4 Future: A Living Language Specification

Prism's semantic completeness positions it to become the de facto specification of Ruby's syntax. Historically, Ruby's syntax has been defined implicitly by what MRI accepts. With Prism, the Ruby core team can now document syntax formally, enabling alternative implementations to verify their compliance programmatically.[21]

This is a critical step toward Ruby's maturation as a language. Just as Python has PEPs (Python Enhancement Proposals) that rigorously define language changes, Ruby can now use Prism's test suite as a living specification, ensuring that all implementations converge on a single, well-defined syntax.[19]

6. Standard Library Reorganization

Ruby 4.0 includes a significant reorganization of the standard library, with several historically bundled gems being promoted to "core" status, and others being rewritten in C for performance.[17]

6.1 Pathname: From Stdlib to Core

The Pathname class, which provides an object-oriented interface for file paths, has been moved from the standard library into Ruby's core. This change reflects its ubiquity: virtually every Ruby application uses Pathname for file manipulation.[4]

By moving Pathname to core, the Ruby team ensures that it is always available without requiring an explicit require statement. This reduces boilerplate and eliminates a common source of confusion for beginners.[13]

6.2 Set: Rewritten in C

The Set class, a collection type that guarantees uniqueness, has been rewritten as a C extension. The previous pure-Ruby implementation was elegant but slow, particularly for large sets. The new C implementation is 5-10x faster for common operations like union, intersection, and membership testing.[4]

This rewrite is part of a broader trend: as Ruby's performance-critical libraries are identified through profiling data, the core team is selectively rewriting them in C or Rust to eliminate hotspots.[23]

6.3 Deprecations and Removals

Several long-deprecated libraries have been removed in Ruby 4.0:

  • DRb (Distributed Ruby): The distributed object framework, largely unused in modern applications, has been moved to a separate gem.[17]
  • Fiddle: The low-level FFI (Foreign Function Interface) library remains but is no longer bundled by default.[4]
  • OpenStruct: The dynamic struct class, which was a source of performance issues, has been replaced by the faster Data class.[13]

These removals reduce the maintenance burden on the core team and encourage the community to adopt more modern alternatives.

7. The Philosophical Shift: Ruby as a Systems Language

Ruby 4.0 represents more than technical improvements; it signals a philosophical shift in how Ruby positions itself in the programming language ecosystem.

7.1 From Scripting to Systems

Ruby emerged in the 1990s as a "scripting language"—a term that implied quick, disposable code for automation tasks. The success of Rails cemented this perception: Ruby was the language of rapid web development, not high-performance systems.[1]

Ruby 4.0 challenges this narrative. With ZJIT's SSA-based compilation, Ractor's parallel execution, and Ruby::Box's process-like isolation, Ruby now possesses the primitives required for systems programming. It is becoming feasible to write:

  • High-performance data processing pipelines (using Ractor::Port)
  • Multi-tenant SaaS platforms (using Ruby::Box)
  • Compilers and interpreters (using Prism as a parsing library)
  • Embedded systems (using WebAssembly-compiled Prism)[1]

These use cases were previously the domain of languages like Go, Rust, or Java. Ruby 4.0 positions itself as a viable alternative, particularly for teams that value developer ergonomics and rapid iteration.[23]

7.2 The "Ruby 4.0" Era

Ruby 4.0 is not an endpoint but a foundation. The introduction of Ruby::Box and ZJIT provides the structural capacity for Ruby to scale to larger codebases (via isolation) and higher performance tiers (via SSA-based compilation). It refutes the narrative of Ruby's decline, offering a roadmap where the language becomes more modular, more concurrent, and more accessible to systems programmers.

For the professional Rubyist, version 4.0 is an invitation to rethink architectural patterns. It moves the language beyond the "Rails Monolith" archetype, offering the primitives to build modular, concurrent systems within a single, coherent process. Thirty years in, Ruby is not just celebrating its past; it is aggressively compiling its future.

8. Summary of Technical Specifications

The following table summarizes the key architectural shifts between Ruby 3.x and Ruby 4.0:

Feature Area Ruby 3.x Strategy Ruby 4.0 Strategy Key Technologies
Compilation YJIT (Lazy Basic Block) Multi-Tier: YJIT (Prod) + ZJIT (Exp) Rust, SSA, HIR, LBBV
Isolation Refinements (Lexical) Boxes (Runtime Container) Ruby::Box, Namespace Copying
Concurrency Ractor 1.0 (Identity-based) Ractor 2.0 (Channel-based) Ractor::Port, Lock-free Structures
Parsing Fragmentation (Ripper/Bison) Unification (Prism) Prism (YARP), Error Tolerance
Dependencies Global Namespace Conflict Isolated Dependency Loading Ruby::Box
Core Libs Pathname (Stdlib), Set (Ruby) Pathname (Core), Set (Native) C-Extension optimizations

9. Conclusion

Ruby 4.0 fulfills the promise of a modern, multi-paradigm language. It balances the human-centric design that Matz prioritized thirty years ago with the machine-centric requirements of modern infrastructure. By solving the hard problems of global state (Ruby::Box), compiler accessibility (ZJIT), and parser unification (Prism), Ruby 4.0 ensures that the language remains not just a "beautiful" choice for developers, but a "smart" choice for architects. The "Christmas Release" of 2025 is a gift of longevity, ensuring Ruby's relevance for decades to come.

Bibliografia

  1. Ruby Turns 30: A Celebration of Code, Community, and Creativity | The RubyMine Blog, accesso eseguito il giorno dicembre 31, 2025, https://blog.jetbrains.com/ruby/2025/12/ruby-turns-30-a-celebration-of-code-community-and-creativity/

  2. What Is New In Ruby 4.0 - Saeloun Blog, accesso eseguito il giorno dicembre 31, 2025, https://blog.saeloun.com/2025/12/24/what-is-new-in-ruby-4/

  3. Ruby 4.0.0 Released, accesso eseguito il giorno dicembre 31, 2025, https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/

  4. Ruby 4.0 changes, accesso eseguito il giorno dicembre 31, 2025, https://rubyreferences.github.io/rubychanges/4.0.html

  5. NEWS - Documentation for Ruby 4.0 : r/ruby - Reddit, accesso eseguito il giorno dicembre 31, 2025, https://www.reddit.com/r/ruby/comments/1owp9o4/news_documentation_for_ruby_40/

  6. Ruby 4.0.0 preview3 Released, accesso eseguito il giorno dicembre 31, 2025, https://www.ruby-lang.org/en/news/2025/12/18/ruby-4-0-0-preview3-released/

  7. ZJIT has been merged into Ruby | Rails at Scale, accesso eseguito il giorno dicembre 31, 2025, https://railsatscale.com/2025-05-14-merge-zjit/

  8. ZJIT is now available in Ruby 4.0 - Rails at Scale, accesso eseguito il giorno dicembre 31, 2025, https://railsatscale.com/2025-12-24-launch-zjit/

  9. Ruby::Box Digest Introduction (Ruby 4.0.0 New Feature) - DEV Community, accesso eseguito il giorno dicembre 31, 2025, https://dev.to/ko1/rubybox-digest-introduction-ruby-400-new-feature-3bch

  10. Organizing monkey patches - ruby - Stack Overflow, accesso eseguito il giorno dicembre 31, 2025, https://stackoverflow.com/questions/41497350/organizing-monkey-patches

  11. Refinement: The Correct Way To Monkey-Patch in Ruby | by reinteractive - Medium, accesso eseguito il giorno dicembre 31, 2025, https://medium.com/@reinteractivehq/refinement-the-correct-way-to-monkey-patch-in-ruby-11d5141d4e72

  12. box - Documentation for Ruby 4.0 - doc.ruby-lang.org, accesso eseguito il giorno dicembre 31, 2025, https://docs.ruby-lang.org/en/master/language/box_md.html

  13. What's new in Ruby 4.0 - The Miners, accesso eseguito il giorno dicembre 31, 2025, https://blog.codeminer42.com/whats-new-in-ruby-4-0/

  14. Ruby 4.0 introduces Ruby::Box — isolated execution without extra processes - Reddit, accesso eseguito il giorno dicembre 31, 2025, https://www.reddit.com/r/rails/comments/1pxorfn/ruby_40_introduces_rubybox_isolated_execution/

  15. class Ruby::Box - Documentation for Ruby 4.1, accesso eseguito il giorno dicembre 31, 2025, https://docs.ruby-lang.org/en/master/Ruby/Box.html

  16. Port` - Revamping the Ractor API - DEV Community, accesso eseguito il giorno dicembre 31, 2025, https://dev.to/ko1/ractorport-revamping-the-ractor-api-98

  17. Changes/Ruby 4.0 - Fedora Project Wiki, accesso eseguito il giorno dicembre 31, 2025, https://fedoraproject.org/wiki/Changes/Ruby_4.0

  18. module Prism - Documentation for Ruby 4.0, accesso eseguito il giorno dicembre 31, 2025, https://docs.ruby-lang.org/en/master/Prism.html

  19. Ruby's New Parser: Why Prism Is the Future of Ruby Development | Super Good Software, accesso eseguito il giorno dicembre 31, 2025, https://supergood.software/rubys-new-prism-parser/

  20. Require prism as a parser to analyse newly released ruby versions #13617 - GitHub, accesso eseguito il giorno dicembre 31, 2025, https://github.com/rubocop/rubocop/issues/13617

  21. Prism - Kevin Newton, accesso eseguito il giorno dicembre 31, 2025, https://kddnewton.com/2024/01/23/prism.html

  22. CRuby switches the default parser from parse.y to Prism : r/ruby - Reddit, accesso eseguito il giorno dicembre 31, 2025, https://www.reddit.com/r/ruby/comments/1fjjbft/cruby_switches_the_default_parser_from_parsey_to/

  23. Optimizing Ruby performance: Observations from thousands of real-world services, accesso eseguito il giorno dicembre 31, 2025, https://www.datadoghq.com/blog/ruby-performance-optimization/

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

C# Minimal API: Multiple Authentication Schemes with Swagger Support

1 Share

Minimal APIs make it easy to get started quickly, but production APIs almost always require authentication and authorization. In real-world systems, it’s also common to support multiple authentication methods for example, JWT for public clients and Basic authentication for internal tooling or service to service access.

Swagger is invaluable for local development and quick endpoint testing, but once multiple authentication schemes are involved, it requires explicit configuration to work correctly.

This article walks through a practical setup for configuring authentication in ASP.NET Core Minimal APIs, including JWT and a custom Basic authentication handler and preparing the API for proper authorization and Swagger integration.

Note: For detailed background about authentication and authorization in Minimal APIs, see the official documentation: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/security

Authentication

To enable authentication in a Minimal API application, authentication services must be registered using AddAuthentication. This registers the authentication infrastructure and allows endpoints to declare authentication requirements.

In this example, JWT Bearer authentication is configured as the default scheme. To use it, you need to install the following NuGet package:

A minimal authentication setup looks like this:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();

var app = builder.Build();

app.MapGet("/", () => "Our Auth Example");
app.Run();

Note: While WebApplication can automatically inject authentication middleware in Minimal API applications, I recommend explicitly calling UseAuthentication() in Program.cs when middleware ordering matters or when clarity is important in larger applications. The same applies to UseAuthorization().

Explicit registration gives you full control over pipeline order—for example, ensuring authentication runs after a global exception-handling middleware so that exceptions thrown by custom authentication handlers or token validation logic are properly captured. The same considerations apply to authorization middleware.

JWT configuration

A basic JWT configuration can be provided via appsettings.json:

  "Authentication": {
    "Schemes": {
      "Bearer": {
        "ValidAudiences": [
          "https://localhost:4200",
          "http://localhost:4200"
        ],
        "ValidIssuer": "secret-issuer"
      }
    }
  },

Note: This example shows audience and issuer validation. For production use, you must also configure token signing key validation. See JWT authentication in ASP.NET Core for complete configuration details.

Custom authentication method: Basic Authentication

In some scenarios, more than one authentication method is required. A common example is exposing internal or administrative endpoints protected by Basic authentication, while public endpoints use JWT.

For demonstration purposes, this example adds a custom Basic authentication scheme.

First, extend appsettings.json with a Basic authentication section:

  "Authentication": {
    "Schemes": {
      "Bearer": {
        "ValidAudiences": [
          "https://localhost:4200",
          "http://localhost:4200"
        ],
        "ValidIssuer": "secret-issuer"
      },
      "Basic": {
        "UserName": "admin",
        "Password": "admin"
      }
    }
  },

Then register the scheme in Program.cs, but don’t forget to also set the DefaultScheme, DefaultChallengeScheme, DefaultAuthenticateScheme properties. This ensures JWT is used as the default for authentication and challenges, while Basic authentication is only applied when explicitly requested via authorization policies.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
     options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
     options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
     options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer()
.AddScheme<BasicAuthOptions, BasicAuthenticationHandler>("Basic", (options) =>
{
     var userName = configuration["Authentication:Schemes:Basic:UserName"];
     var password = configuration["Authentication:Schemes:Basic:Password"];

     if (string.IsNullOrWhiteSpace(userName))
     {
          throw new InvalidOperationException("Basic authentication username is not configured.");
     }

     if (string.IsNullOrWhiteSpace(password))
     {
          throw new InvalidOperationException("Basic authentication password is not configured.");
     }

     options.UserName = userName;
     options.Password = password;
});

var app = builder.Build();

app.MapGet("/", () => "Our Auth Example");
app.Run();

Here, AddScheme<TOptions, THandler> registers a custom authentication scheme backed by a custom authentication handler. The options are bound from configuration and passed to the handler at runtime.

Create AuthenticationConfiguration

As we are always trying to keep our Program.cs file as clean as possible, let’s create an extension method that configures authentication:

public static class BasicSchemeDefaults
{
    public static readonly string AuthenticationScheme = "Basic";
}

public static class AuthenticationConfiguration
{
    public static IServiceCollection ConfigureAuthentication(this IServiceCollection serviceCollection, IConfiguration configuration)
    {
        serviceCollection.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer().AddScheme<BasicAuthOptions, BasicAuthenticationHandler>(BasicSchemeDefaults.AuthenticationScheme, (options) =>
        {
            var userName = configuration["Authentication:Schemes:Basic:UserName"];
            var password = configuration["Authentication:Schemes:Basic:Password"];

            if (string.IsNullOrWhiteSpace(userName))
            {
                throw new InvalidOperationException(
                    "Basic authentication username is not configured.");
            }

            if (string.IsNullOrWhiteSpace(password))
            {
                throw new InvalidOperationException(
                    "Basic authentication password is not configured.");
            }

            options.UserName = userName;
            options.Password = password;
        });

        return serviceCollection;
    }
}

Implementing BasicAuthenticationHandler

To create a custom authentication handler, the handler must implement IAuthenticationHandler. In practice, this is usually done by inheriting from AuthenticationHandler<TOptions>.

The handler’s responsibility is to extract credentials from the request, validate them, and produce an AuthenticateResult.

public sealed class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthOptions>
{
    public BasicAuthenticationHandler(
        IOptionsMonitor<BasicAuthOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder)
    {
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeader))
        {
            return AuthenticateResult.Fail("Missing Authorization header");
        }

        if (!AuthenticationHeaderValue.TryParse(authorizationHeader.ToString(), out var authHeader))
        {
            return AuthenticateResult.Fail("Invalid Authorization header");
        }

        if (string.IsNullOrEmpty(authHeader?.Parameter))
        {
            return AuthenticateResult.Fail("Missing Authorization Header parameter");
        }

        Span<byte> bytesBuffer = stackalloc byte[authHeader!.Parameter!.Length];
        if (!Convert.TryFromBase64String(authHeader.Parameter, bytesBuffer, out var bytesWritten))
        {
            return AuthenticateResult.Fail("Invalid Base64 string");
        }

        var credentials = Encoding.UTF8.GetString(bytesBuffer[..bytesWritten]).Split(':', 2);

        if (credentials.Length != 2)
        {
            return AuthenticateResult.Fail("Invalid credential format");
        }

        if (credentials[0] != this.Options.UserName || credentials[1] != this.Options.Password)
        {
            return AuthenticateResult.Fail("Invalid credentials");
        }

        var claims = new List<Claim>()
        {
            new(ClaimTypes.Name, credentials[0]),
            new(ClaimTypes.Role, AuthRoles.Administrator)
        };

        var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name);
        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

        return AuthenticateResult.Success(
            new AuthenticationTicket(
                claimsPrincipal,
                Scheme.Name));
    }
}

Authentication options

The options class used by the handler must inherit from AuthenticationSchemeOptions:

public sealed class BasicAuthOptions : AuthenticationSchemeOptions
{
   public const string SectionName = "Basic";
   public string UserName { get; set; } = string.Empty;
   public string Password { get; set; } = string.Empty;
}

Important This Basic authentication implementation is intended for demonstration. Credentials must never be stored in appsettings.json in production. Use secure storage such as environment variables, secret managers, or Azure Key Vault.
Always use Basic authentication over HTTPS. Without HTTPS, credentials are exposed to anyone listening on the network, making your API extremely vulnerable.

Authorization

Authentication answers who the user is. Authorization answers what the user is allowed to do. In most real-world APIs, authenticated users do not have the same permissions, which means authorization policies are required.

ASP.NET Core authorization is policy-based. Policies can be scoped to:

  • Specific authentication schemes
  • Required roles or claims
  • Custom requirements

In this example, the API supports two authentication schemes:

  • JWT Bearer authentication
  • Basic authentication

Each scheme requires its own authorization rules.

Authorization configuration

To keep authorization setup explicit and reusable, policies are defined in a dedicated configuration class:

public static class AuthorizationConfiguration
{
    public static readonly string BasicPolicyName = "basicPolicy";
    public static readonly string UserPolicyName = "userPolicy";
    public static readonly string DeveloperPolicyName = "developerPolicy";
    public static readonly string AdministratorPolicyName = "administratorPolicy";

    public static IServiceCollection ConfigureAuthorization(this IServiceCollection serviceCollection)
    {
        serviceCollection.AddAuthorization(options =>
        {
            options.AddBearerPolicy(UserPolicyName, AuthRoles.AllRoles);
            options.AddBearerPolicy(DeveloperPolicyName, [AuthRoles.Developer, AuthRoles.Administrator]);
            options.AddBearerPolicy(AdministratorPolicyName, [AuthRoles.Administrator]);
            options.AddPolicy(BasicPolicyName, options =>
            {
                options.RequireAuthenticatedUser();
                options.AddAuthenticationSchemes(BasicSchemeDefaults.AuthenticationScheme);
            });
        });

        return serviceCollection;
    }

    public static void AddBearerPolicy(this AuthorizationOptions authorizationOptions, string policyName, IEnumerable<string> roles)
    {
        authorizationOptions.AddPolicy(policyName, policy =>
        {
          policy.RequireAuthenticatedUser();
          policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
          policy.RequireRole(roles);

        });
    }
}

Why AddAuthenticationSchemes matters

When multiple authentication schemes are registered, authorization policies must explicitly specify which scheme they apply to.

Without AddAuthenticationSchemes:

  • ASP.NET Core may attempt to authenticate using the default scheme, leading to unexpected failures when multiple schemes are registered.
  • Requests may fail even with valid credentials
  • Swagger testing becomes unreliable

In this configuration:

  • JWT-based policies explicitly require the Bearer scheme
  • The Basic policy explicitly requires the Basic scheme

This guarantees predictable behavior when both authentication methods coexist.

Role-based authorization for JWT

JWT policies use role-based authorization:

policy.RequireAuthenticatedUser();
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
policy.RequireRole(roles);

This assumes roles are:

  • Embedded in the JWT token
  • Mapped to ClaimTypes.Role (or configured accordingly)

Each policy restricts access to a specific role set:

  • User policy → any authenticated user
  • Developer policy → developer or administrator
  • Administrator policy → administrator only

Basic authentication authorization

The Basic authentication policy is intentionally simple:

policy.RequireAuthenticatedUser();
policy.AddAuthenticationSchemes(BasicSchemeDefaults.AuthenticationScheme);

This ensures:

  • Only authenticated Basic credentials are accepted
  • Authorization remains explicit and isolated

Applying authorization to endpoints

To use it in our endpoints we have to call RequireAuthorization method with specific policy name:

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureAuthentication(builder.Configuration);
builder.Services.ConfigureAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/jwt", () => "Our jwt Auth Example")
.RequireAuthorization(AuthorizationConfiguration.UserPolicyName);
app.MapGet("/basic", () => "Our basic Auth Example")
.RequireAuthorization(AuthorizationConfiguration.BasicPolicyName);
app.Run();

Each endpoint clearly declares:

  • Which policy it requires
  • Which authentication scheme is expected
  • Which roles (if any) are allowed

Swagger

And finally swagger configuration. Let's start from basics. Extension method below configures Swagger and adds it to your dependency injection container. And with SwaggerDoc creates the main Swagger document with version "v1".

public static IServiceCollection ConfigureSwagger(this IServiceCollection serviceCollection)
{
    return serviceCollection.AddSwaggerGen(options =>
    {
        options.SwaggerDoc(_version, new OpenApiInfo()
        {
            Version = _version,
        });
    });
}

JWT Bearer Authentication Setup

options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, CreateScheme());

options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
    [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, document)] = []
});

What this does:

  • AddSecurityDefinition - Tells Swagger "this API uses JWT Bearer tokens for authentication"
  • AddSecurityRequirement - Tells Swagger "users must provide a JWT token to test endpoints"

The CreateScheme() method defines how JWT authentication works:

private static OpenApiSecurityScheme CreateScheme()
{
    return new OpenApiSecurityScheme()
    {
        Name = "JWT Bearer token",          // Display name in Swagger UI
        Type = SecuritySchemeType.Http,     // Uses HTTP authentication
        Scheme = JwtBearerDefaults.AuthenticationScheme,  // "Bearer - Scheme name"
        BearerFormat = "JWT",               // Token format
        Description = "JWT Bearer token Authorization"
    };
}

Basic Authentication Setup

options.AddSecurityDefinition(BasicSchemeDefaults.AuthenticationScheme, CreateBasicScheme());

options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
    [new OpenApiSecuritySchemeReference(BasicSchemeDefaults.AuthenticationScheme, document)] = []
});

Same pattern as JWT, but for Basic authentication (username + password).

The CreateBasicScheme() method:

private static OpenApiSecurityScheme CreateBasicScheme()
{
    return new OpenApiSecurityScheme()
    {
        Name = "Basic Authorization",
        Type = SecuritySchemeType.Http,
        Scheme = BasicSchemeDefaults.AuthenticationScheme,  // "Basic" Scheme name
        In = ParameterLocation.Header,      // Sent in HTTP headers
        Description = "Enter your username and password."
    };
}

Let's put it together in one final Swagger UI extension method:

public static class SwaggerConfiguration
{
    private static readonly string _version = "v1";
    public static IServiceCollection ConfigureSwagger(this IServiceCollection serviceCollection)
    {
        return serviceCollection.AddSwaggerGen(options =>
        {
            options.SwaggerDoc(_version, CreateInfo());
            options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, CreateScheme());
            options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, document)] = []
            });
            options.AddSecurityDefinition(BasicSchemeDefaults.AuthenticationScheme, CreateBasicScheme());
            options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(BasicSchemeDefaults.AuthenticationScheme, document)] = []
            });
        });
    }

    private static OpenApiSecurityScheme CreateScheme()
    {
        return new OpenApiSecurityScheme()
        {
            Name = "JWT Bearer token",
            Type = SecuritySchemeType.Http,
            Scheme = JwtBearerDefaults.AuthenticationScheme,
            BearerFormat = "JWT",
            Description = "JWT Bearer token Authorization",
        };
    }

    private static OpenApiSecurityScheme CreateBasicScheme()
    {
        return new OpenApiSecurityScheme()
        {
            Name = "Basic Authorization",
            Type = SecuritySchemeType.Http,
            Scheme = BasicSchemeDefaults.AuthenticationScheme,
            In = ParameterLocation.Header,
            Description = "Enter your username and password.",
        };
    }

    private static OpenApiInfo CreateInfo()
    {
        return new OpenApiInfo()
        {
            Version = _version,
        };
    }

Note: Swagger security definitions are declared at the API level and describe which authentication mechanisms are supported globally. Actual access control such as JWT or Basic policies is enforced per endpoint through your authorization policies.

And program.cs will look like this:

var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureSwagger();
builder.Services.ConfigureAuthentication(builder.Configuration);
builder.Services.ConfigureAuthorization();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/jwt", () => "Our jwt Auth Example").RequireAuthorization(AuthorizationConfiguration.UserPolicyName);
app.MapGet("/basic", () => "Our basic Auth Example").RequireAuthorization(AuthorizationConfiguration.BasicPolicyName);
app.Run();

When you open Swagger UI you'll see:

  1. 🔒 Authorize Button - Click to authenticate
  2. Two authentication options:
    • Bearer - Paste your JWT token
    • Basic - Enter username/password

Note Usually UseSwagger and UseSwaggerUI methods are under if statement because we use them in development environments:

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

Solution Examples

The following example project demonstrates multiple authentication schemes with swagger support: AuthApi

Conclusion

Securing Minimal APIs requires deliberate design and careful implementation. By combining multiple authentication schemes, clearly defined authorization policies, and a properly configured Swagger setup, you ensure your endpoints remain predictable, secure, and maintainable, even as your API scales.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

5 Flutter Decisions I’d Make Differently If I Started Today

1 Share

When I first started working with Flutter, my priorities were speed and momentum. I wanted to ship screens quickly, explore the framework’s flexibility, and prove that I could turn ideas into working applications. Like many developers, I learned Flutter through tutorials, sample projects, and experimentation.

That phase was necessary — but it also shaped decisions that later became expensive.

Only after shipping real applications, maintaining them over time, and living with the consequences of early choices did I realize something important: most Flutter problems don’t come from lack of knowledge — they come from early assumptions that go unquestioned.

This article is not a guide for beginners, nor is it a list of “best practices.”
It’s a reflection on five decisions that felt reasonable at the time, but revealed their cost months later.

If I were starting Flutter today, with what I know now, these are the decisions I would approach very differently.

1. I Would Define Change Boundaries Before Writing UI

What I did before

In my early Flutter projects, I treated UI as the starting point. I designed screens, built widgets, wired interactions, and then slowly layered logic underneath. This felt natural — Flutter makes UI expressive and enjoyable to write, so it’s tempting to start there.

The problem was not that UI came first.
The problem was that UI quietly became the place where decisions lived.

Business rules leaked into widgets. Conditional logic spread across build methods. Navigation decisions became tightly coupled to UI state. At first, everything worked. Over time, small changes began to feel risky.

What broke later

As the application evolved:

  • Screens that looked simple controlled too much behavior
  • Reusing logic across flows became painful
  • A UI change could unexpectedly affect data handling
  • Refactoring required deep context and caution

Nothing was “wrong” in isolation. But the cost of change increased steadily.

What I would do today

Now, I start by asking:

  • What is likely to change?
  • What must remain stable?
  • Which rules belong to the product, not the screen?

Before building UI, I define clear boundaries:

  • UI displays state, it does not decide it
  • Business logic lives outside widgets
  • Navigation is driven by intent, not UI conditionals

This approach doesn’t slow development — it protects it.

When change arrives (and it always does), the app bends instead of cracking.

2. I Would Treat State Management as a Cognitive Load Decision

What I did before

Like many Flutter developers, I initially treated state management as a tooling problem. I compared libraries, followed community preferences, and chose solutions that looked clean or modern.

At the time, the question was:

“Which state management solution should I use?”

That was the wrong question.

The real cost I underestimated

Over time, I noticed that the biggest issues weren’t performance or features — they were mental friction:

  • Debugging required jumping across abstractions
  • Understanding data flow took effort
  • Returning to old code felt heavier than expected

The app worked, but it wasn’t easy to think about.

That’s when it clicked:
State management is not about code — it’s about cognition.

What I would do today

Now, I choose state management based on:

  • Explicit data flow
  • Predictability of side effects
  • Ease of reasoning during debugging

I ask myself:

  • Can I explain this flow without diagrams?
  • Can I trace state changes quickly?
  • Will this make future changes safer or harder?

The best solution is not the most flexible — it’s the one that reduces thinking overhead.

In production, clarity beats elegance every time.

3. I Would Stop Optimizing for Reuse and Start Optimizing for Understanding

What I did before

Earlier in my career, I believed reuse was always good. If logic appeared twice, I abstracted it. If patterns repeated, I generalized them. The codebase became “clean” — but also increasingly indirect.

The intention was good.
The outcome was not.

The hidden cost of clever abstractions

Months later, I noticed:

  • Reading code required jumping through layers
  • Simple changes demanded global understanding
  • Bugs hid inside generic helpers

Worse, I sometimes had to re-learn my own abstractions.

The code was reusable — but not readable.

What I would do today

Today, I value local clarity over global reuse.

I prefer:

  • Slight duplication with clear intent
  • Explicit logic over generic helpers
  • Straightforward flows over abstract patterns

If a piece of code is important, I want it to be:

  • Easy to find
  • Easy to read
  • Easy to change

Reusable code is not automatically good code.
Understandable code is.

4. I Would Treat Performance as an Architectural Habit, Not a Fix

What I did before

In early projects, performance was reactive. If something felt slow, I optimized it. If users complained, I investigated. Flutter’s performance tools made this feel manageable.

But performance issues rarely appear in isolation.

What actually happened

Over time:

  • Rebuilds became expensive
  • Widget trees grew heavy
  • Small UI changes had unexpected cost

Fixing performance late meant:

  • Risky refactors
  • Time spent chasing symptoms
  • Compromises in UX

The real issue wasn’t lack of optimization — it was lack of intention.

What I would do today

Now, I treat performance as a design habit:

  • Clear rebuild boundaries
  • Conscious widget composition
  • Awareness of what actually triggers work

Not premature optimization — deliberate structure.

Flutter rewards developers who respect how the framework works.
Ignoring that always shows up later.

5. I Would Think Like a Maintainer From the First Commit

This is the most important change.

What I did before

I thought like a builder:

  • Can this work?
  • Can I ship this?
  • Can I add features quickly?

And that worked — initially.

What maintenance taught me

Over time, I realized:

  • Apps slow down because people fear touching code
  • Velocity drops when intent is unclear
  • Technical debt is often emotional, not technical

Code that feels unsafe to change becomes frozen.

What I would do today

If I started today, I would think like someone who must live with the code:

  • Clear folder boundaries
  • Small comments explaining why, not what
  • Consistent patterns across the app

I would optimize for:

  • Confidence
  • Safety
  • Ease of change

Shipping is an event. Maintenance is a long-term commitment.

Closing Reflection

Flutter is not the reason most apps struggle long-term.
The framework is capable, expressive, and powerful.

What fails is how we think when we start.

If I began again today, I wouldn’t aim to be faster.
I would aim to be clearer — in intent, structure, and trade-offs.

That clarity compounds over time.

About the Author

I’m Abdul Wahab, a Flutter developer and product builder focused on building production-ready applications with long-term maintainability in mind.

I occasionally share practical reflections from real projects on:

These thoughts are based on hands-on experience and continue to evolve with every product shipped.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Fears Mount That US Federal Cybersecurity Is Stagnating—or Worse

1 Share
Government staffing cuts and instability, including this year’s prolonged shutdown, could be hindering US digital defense and creating vulnerabilities.
Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories