Class: Steppe::Endpoint

Inherits:
Plumb::Pipeline
  • Object
show all
Defined in:
lib/steppe/endpoint.rb

Overview

Endpoint represents a single API endpoint with request validation, processing, and response handling.

Inherits from Plumb::Pipeline to provide composable request processing through steps. Each endpoint defines an HTTP verb, URL path pattern, input validation schemas, processing logic, and response serialization strategies.

Examples:

Basic endpoint definition

endpoint = Endpoint.new(:users_list, :get, path: '/users') do |e|
  # Define query parameter validation
  e.query_schema(
    page: Types::Integer.default(1),
    per_page: Types::Integer.default(20)
  )

  # Add processing steps
  e.step do |result|
    users = User.limit(result.params[:per_page]).offset((result.params[:page] - 1) * result.params[:per_page])
    result.continue(data: users)
  end

  # Define response serialization
  e.respond 200, :json, UserListSerializer
end

Endpoint with payload validation

endpoint = Endpoint.new(:create_user, :post, path: '/users') do |e|
  e.payload_schema(
    name: Types::String,
    email: Types::String.email
  )

  e.step do |result|
    user = User.create(result.params)
    result.respond_with(201).continue(data: user)
  end

  e.respond 201, :json, UserSerializer
end

See Also:

Defined Under Namespace

Classes: BodyParser, DefaultEntitySerializer, HeaderValidator, PayloadValidator, QueryValidator, SecurityStep

Constant Summary collapse

MatchContentType =

These types are used in the respond method pattern matching.

Types::String[ContentType::MIME_TYPE] | Types::Symbol
MatchStatus =
Types::Integer | Types::Any[Range]
FALLBACK_RESPONDER =

Fallback responder used when no matching responder is found for a status/content-type combination. Returns a JSON error message indicating the missing responder configuration.

Responder.new(statuses: (100..599), accepts: 'application/json') do |r|
  r.serialize do
    attribute :message, String
    def message = "no responder registered for response status: #{result.response.status}"
  end
end
DefaultHTMLSerializer =
-> (conn) {
  html5 {
    head {
      title "Default #{conn.response.status}"
    }
    body {
      h1 "Default view"
      dl {
        dt "Response status:"
        dd conn.response.status.to_s
        dt "Parameters:"
        dd conn.params.inspect
        dt "Errors:"
        dd conn.errors.inspect
      }
    }
  }
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(service, rel_name, verb, path: '/') {|endpoint| ... } ⇒ Endpoint

Creates a new endpoint instance.

Examples:

Endpoint.new(:users_show, :get, path: '/users/:id') do |e|
  e.step { |result| result.continue(data: User.find(result.params[:id])) }
  e.respond 200, :json, UserSerializer
end

Parameters:

  • rel_name (Symbol)

    Relation name for this endpoint (e.g., :users_list, :create_user)

  • verb (Symbol)

    HTTP verb (:get, :post, :put, :patch, :delete, etc.)

  • path (String) (defaults to: '/')

    URL path pattern (supports Mustermann syntax, e.g., ‘/users/:id’)

Yields:

  • (endpoint)

    Configuration block that receives the endpoint instance



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/steppe/endpoint.rb', line 251

def initialize(service, rel_name, verb, path: '/', &)
  # Do not setup with block yet
  super(freeze_after: false, &nil)
  @service = service
  @rel_name = rel_name
  @verb = verb
  @responders = ResponderRegistry.new
  @query_schema = Types::Hash
  @header_schema = Types::Hash
  @payload_schemas = {}
  @body_parsers = {}
  @registered_security_schemes = {}
  @description = 'An endpoint'
  @specced = true
  @tags = []

  # This registers security schemes declared in the service
  # which may declare their own header, query or payload schemas
  service.registered_security_schemes.each do |name, scopes|
    security name, scopes
  end

  # This registers a query_schema
  # and a QueryValidator step
  self.path = path

  configure(&) if block_given?

  # Register default responders for common status codes
  respond 204, :json
  respond 304, :json
  respond 200..299, :json, DefaultEntitySerializer
  # TODO: match any content type
  # respond 304, '*/*'
  respond 401..422, :json, DefaultEntitySerializer
  respond 401..422, :html, DefaultHTMLSerializer
  freeze
end

Instance Attribute Details

#descriptionObject

Returns the value of attribute description.



237
238
239
# File 'lib/steppe/endpoint.rb', line 237

def description
  @description
end

#pathObject

Returns the value of attribute path.



236
237
238
# File 'lib/steppe/endpoint.rb', line 236

def path
  @path
end

#payload_schemasObject (readonly)

Returns the value of attribute payload_schemas.



236
237
238
# File 'lib/steppe/endpoint.rb', line 236

def payload_schemas
  @payload_schemas
end

#registered_security_schemesObject (readonly)

Returns the value of attribute registered_security_schemes.



236
237
238
# File 'lib/steppe/endpoint.rb', line 236

def registered_security_schemes
  @registered_security_schemes
end

#rel_nameObject (readonly)

Returns the value of attribute rel_name.



236
237
238
# File 'lib/steppe/endpoint.rb', line 236

def rel_name
  @rel_name
end

#respondersObject (readonly)

Returns the value of attribute responders.



236
237
238
# File 'lib/steppe/endpoint.rb', line 236

def responders
  @responders
end

#tagsObject

Returns the value of attribute tags.



237
238
239
# File 'lib/steppe/endpoint.rb', line 237

def tags
  @tags
end

Instance Method Details

#call(conn) ⇒ Result

Main processing method that runs the endpoint pipeline and handles response.

Flow:

  1. Runs all registered steps (query validation, payload validation, business logic)

  2. Resolves appropriate responder based on status code and Accept header

  3. Runs responder pipeline to serialize and format response

Parameters:

  • conn (Result)

    Initial result/connection object

Returns:

  • (Result)

    Final result with serialized response



668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/steppe/endpoint.rb', line 668

def call(conn)
  known_query_names = query_schema._schema.keys.map(&:to_sym)
  known_query = conn.request.steppe_url_params.slice(*known_query_names)
  conn.request.set_url_params!(known_query)
  conn = super(conn)
  accepts = conn.request.get_header('HTTP_ACCEPT') || ContentTypes::JSON
  responder = responders.resolve(conn.response.status, accepts) || FALLBACK_RESPONDER
  # Conn might be a Halt now, because a step halted processing.
  # We set it back to Continue so that the responder pipeline
  # can process it through its steps.
  responder.call(conn.valid)
end

#debug!void

This method returns an undefined value.

Adds a debugging breakpoint step to the endpoint pipeline. Useful for development and troubleshooting.



641
642
643
644
645
646
# File 'lib/steppe/endpoint.rb', line 641

def debug!
  step do |conn|
    debugger
    conn
  end
end

#header_schema(schema) ⇒ void #header_schemaPlumb::Composable

Note:

HTTP header names in Rack env use the format ‘HTTP_*’ (e.g., ‘HTTP_AUTHORIZATION’)

Note:

Optional headers can be specified with a ‘?’ suffix (e.g., ‘HTTP_X_CUSTOM?’)

Note:

Security schemes automatically add their header requirements via SecurityStep

Defines or returns the HTTP header validation schema.

When called with a schema argument, registers a HeaderValidator step to validate HTTP headers. When called without arguments, returns the current header schema. Header schemas are automatically merged from security schemes and other composable steps.

Overloads:

  • #header_schema(schema) ⇒ void

    This method returns an undefined value.

    Define header validation schema

    Examples:

    Validate custom header

    header_schema(
      'HTTP_X_API_VERSION' => Types::String.options(['v1', 'v2']),
      'HTTP_X_REQUEST_ID?' => Types::String.present
    )

    Validate Authorization header manually

    header_schema(
      'HTTP_AUTHORIZATION' => Types::String[/^Bearer .+/]
    )

    Parameters:

    • schema (Hash, Plumb::Composable)

      Schema definition for HTTP headers

  • #header_schemaPlumb::Composable

    Get current header schema

    Returns:

    • (Plumb::Composable)

      Current header schema

See Also:



404
405
406
407
408
409
410
# File 'lib/steppe/endpoint.rb', line 404

def header_schema(sc = nil)
  if sc
    step(HeaderValidator.new(sc))
  else
    @header_schema
  end
end

#html(statuses = (200...300), view = nil) { ... } ⇒ self

Convenience method to define an HTML responder.

Examples:

html 200, UserShowView

Parameters:

  • statuses (Integer, Range) (defaults to: (200...300))

    Status code(s) to respond to (default: 200-299)

  • view (Class, Proc, nil) (defaults to: nil)

    Optional view class or block

Yields:

  • Optional block defining view inline

Returns:

  • (self)

    Returns self for method chaining



528
529
530
531
532
# File 'lib/steppe/endpoint.rb', line 528

def html(statuses = (200...300), view = nil, &block)
  respond(statuses, :html, view || block)

  self
end

#inspectObject



290
291
292
# File 'lib/steppe/endpoint.rb', line 290

def inspect
  %(<#{self.class}##{object_id} [#{rel_name}] #{verb.to_s.upcase} #{path}>)
end

#json(statuses = (200...300), serializer = nil) {|serializer| ... } ⇒ self

Convenience method to define a JSON responder.

Examples:

With serializer class

json 200, UserSerializer

With inline block

json 200 do
  attribute :name, String
  def name = result.data[:name]
end

Parameters:

  • statuses (Integer, Range) (defaults to: (200...300))

    Status code(s) to respond to (default: 200-299)

  • serializer (Class, Proc, nil) (defaults to: nil)

    Optional serializer class or block

Yields:

  • (serializer)

    Optional block defining serializer inline

Returns:

  • (self)

    Returns self for method chaining



510
511
512
513
514
515
516
517
# File 'lib/steppe/endpoint.rb', line 510

def json(statuses = (200...300), serializer = nil, &block)
  respond(statuses:, accepts: :json) do |r|
    r.description = "Response for status #{statuses}"
    r.serialize serializer || block
  end

  self
end

#no_spec!Object



300
# File 'lib/steppe/endpoint.rb', line 300

def no_spec! = @specced = false

#node_nameObject

Node name for OpenAPI documentation



303
# File 'lib/steppe/endpoint.rb', line 303

def node_name = :endpoint

#payload_schema(schema) ⇒ Object #payload_schema(content_type, schema) ⇒ Object

Defines request body validation schema for a specific content type.

Automatically registers a BodyParser step for the content type if not already registered, then registers a PayloadValidator step to validate the parsed body.

Overloads:

  • #payload_schema(schema) ⇒ Object

    Define JSON payload schema (default content type)

    Examples:

    payload_schema(
      name: Types::String,
      email: Types::String.email
    )

    Parameters:

    • schema (Hash, Plumb::Composable)

      Schema definition

  • #payload_schema(content_type, schema) ⇒ Object

    Define payload schema for specific content type

    Examples:

    payload_schema('application/xml', XMLUserSchema)

    Parameters:

    • content_type (String)

      Content type (e.g., ‘application/xml’)

    • schema (Hash, Plumb::Composable)

      Schema definition

Raises:

  • (ArgumentError)

    if arguments don’t match expected patterns



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/steppe/endpoint.rb', line 458

def payload_schema(*args)
  ctype, stp = case args
  in [Hash => sc]
    [ContentTypes::JSON, sc]
  in [Plumb::Composable => sc]
    [ContentTypes::JSON, sc]
  in [MatchContentType => content_type, Hash => sc]
    [content_type, sc]
  in [MatchContentType => content_type, Plumb::Composable => sc]
    [content_type, sc]
  else
    raise ArgumentError, "Invalid arguments: #{args.inspect}. Expects [Hash] or [Plumb::Composable], and an optional content type."
  end

  content_type = ContentType.parse(ctype)
  unless @body_parsers[content_type]
    step BodyParser.build(content_type)
    @body_parsers[ctype] = true
  end
  step PayloadValidator.new(content_type, stp)
end

#query_schema(schema) ⇒ void #query_schemaPlumb::Composable

Defines or returns the query parameter validation schema.

When called with a schema argument, registers a QueryValidator step to validate query parameters. When called without arguments, returns the current query schema.

Overloads:

  • #query_schema(schema) ⇒ void

    This method returns an undefined value.

    Examples:

    query_schema(
      page: Types::Integer.default(1),
      search: Types::String.optional
    )

    Parameters:

    • schema (Hash, Plumb::Composable)

      Schema definition for query parameters

  • #query_schemaPlumb::Composable

    Returns Current query schema.

    Returns:

    • (Plumb::Composable)

      Current query schema



428
429
430
431
432
433
434
# File 'lib/steppe/endpoint.rb', line 428

def query_schema(sc = nil)
  if sc
    step(QueryValidator.new(sc))
  else
    @query_schema
  end
end

#respond(status) {|responder| ... } ⇒ self #respond(status, accepts) {|responder| ... } ⇒ self #respond(status, accepts, serializer) {|responder| ... } ⇒ self #respond(status_range, accepts, serializer) {|responder| ... } ⇒ self #respond(responder) ⇒ self #respond(**options) {|responder| ... } ⇒ self

Note:

Responders are resolved by ResponderRegistry using status code and Accept header

Note:

When ranges overlap, first registered responder wins

Note:

Default accept type is :json (application/json) when not specified

Define how the endpoint responds to specific HTTP status codes and content types.

Responders are registered in order and when ranges overlap, the first registered responder wins. This allows you to define specific handlers first, then fallback handlers for broader ranges.

Overloads:

  • #respond(status) {|responder| ... } ⇒ self

    Basic responder for a single status code

    Examples:

    respond 200  # Basic 200 response
    respond 404 do |r|
      r.serialize ErrorSerializer
    end

    Parameters:

    • status (Integer)

      HTTP status code

    Yields:

    • (responder)

      Optional configuration block

  • #respond(status, accepts) {|responder| ... } ⇒ self

    Responder for specific status and content type

    Examples:

    respond 200, :json
    respond 404, 'text/html' do |r|
      r.serialize ErrorPageView
    end

    Parameters:

    • status (Integer)

      HTTP status code

    • accepts (String, Symbol)

      Content type (e.g., :json, ‘application/json’)

    Yields:

    • (responder)

      Optional configuration block

  • #respond(status, accepts, serializer) {|responder| ... } ⇒ self

    Responder with predefined serializer

    Examples:

    respond 200, :json, UserListSerializer
    respond 404, :json, ErrorSerializer

    Parameters:

    • status (Integer)

      HTTP status code

    • accepts (String, Symbol)

      Content type

    • serializer (Class, Proc)

      Serializer class or block

    Yields:

    • (responder)

      Optional configuration block

  • #respond(status_range, accepts, serializer) {|responder| ... } ⇒ self

    Responder for a range of status codes

    Examples:

    # First registered wins in overlaps
    respond 201, :json, CreatedSerializer     # Specific handler for 201
    respond 200..299, :json, SuccessSerializer # Fallback for other 2xx

    Parameters:

    • status_range (Range)

      Range of HTTP status codes

    • accepts (String, Symbol)

      Content type

    • serializer (Class, Proc)

      Serializer class or block

    Yields:

    • (responder)

      Optional configuration block

  • #respond(responder) ⇒ self

    Add a pre-configured Responder instance

    Examples:

    custom = Steppe::Responder.new(statuses: 200, accepts: :xml) do |r|
      r.serialize XMLUserSerializer
    end
    respond custom

    Parameters:

    • responder (Responder)

      Pre-configured responder

  • #respond(**options) {|responder| ... } ⇒ self

    Responder with keyword arguments

    Examples:

    respond statuses: 200..299, accepts: :json do |r|
      r.serialize SuccessSerializer
    end

    Yields:

    • (responder)

      Optional configuration block

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    When invalid argument combinations are provided

See Also:



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
# File 'lib/steppe/endpoint.rb', line 613

def respond(*args, &)
  case args
  in [Responder => responder]
    @responders << responder

  in [MatchStatus => statuses]
    @responders << Responder.new(statuses:, &)

  in [MatchStatus => statuses, MatchContentType => accepts]
    @responders << Responder.new(statuses:, accepts:, &)

  in [MatchStatus => statuses, MatchContentType => accepts, Object => serializer]
    @responders << Responder.new(statuses:, accepts:, serializer:, &)

  in [Hash => kargs]
    @responders << Responder.new(**kargs, &)

  else
    raise ArgumentError, "Invalid arguments: #{args.inspect}"
  end

  self
end

#run(request) ⇒ Result

Executes the endpoint pipeline for a given request.

Creates an initial Continue result and runs it through the pipeline.

Parameters:

Returns:

  • (Result)

    Processing result (Continue or Halt)



654
655
656
657
# File 'lib/steppe/endpoint.rb', line 654

def run(request)
  result = Result::Continue.new(nil, request:)
  call(result)
end

#security(scheme_name, scopes = []) ⇒ void

Note:

If authentication fails, returns 401 Unauthorized

Note:

If authorization fails (missing required scopes), returns 403 Forbidden

This method returns an undefined value.

Apply a security scheme to this endpoint with required scopes. The security scheme must be registered in the parent Service using #security_scheme, #bearer_auth, or #basic_auth. This adds a processing step that validates authentication/authorization before other endpoint logic runs.

Examples:

Basic usage with Bearer authentication

service.bearer_auth 'api_key', store: {
  'token123' => ['read:users', 'write:users']
}

service.get :users, '/users' do |e|
  e.security 'api_key', ['read:users']  # Only tokens with read:users scope can access
  e.step { |result| result.continue(data: User.all) }
  e.json 200, UserListSerializer
end

Basic HTTP authentication

service.basic_auth 'BasicAuth', store: {
  'admin' => 'secret123',
  'user' => 'password456'
}

service.get :protected, '/protected' do |e|
  e.security 'BasicAuth'  # Basic auth doesn't use scopes
  e.step { |result| result.continue(data: { message: 'Protected resource' }) }
  e.json 200
end

Multiple scopes required (Bearer only)

service.get :admin_users, '/admin/users' do |e|
  e.security 'api_key', ['read:users', 'admin:access']
  # ... endpoint definition
end

Parameters:

  • scheme_name (String)

    Name of the security scheme (must match a registered scheme)

  • scopes (Array<String>) (defaults to: [])

    Required permission scopes for this endpoint (not used for Basic auth)

Raises:

  • (KeyError)

    If the security scheme is not registered in the parent service

See Also:



366
367
368
369
370
371
# File 'lib/steppe/endpoint.rb', line 366

def security(scheme_name, scopes = [])
  scheme = service.security_schemes.fetch(scheme_name)
  scheme_step = SecurityStep.new(scheme, scopes:)
  @registered_security_schemes[scheme.name] = scopes
  step scheme_step
end

#specced?Boolean

Returns:

  • (Boolean)


299
# File 'lib/steppe/endpoint.rb', line 299

def specced? = @specced

#to_rackProc

Returns Rack-compatible application callable.

Returns:

  • (Proc)

    Rack-compatible application callable



295
296
297
# File 'lib/steppe/endpoint.rb', line 295

def to_rack
  proc { |env| run(Steppe::Request.new(env)).response }
end

#verb(verb) ⇒ Symbol #verbSymbol

Gets or sets the HTTP verb for this endpoint.

Overloads:

  • #verb(verb) ⇒ Symbol

    Sets the HTTP verb

    Parameters:

    • verb (Symbol)

      HTTP verb (:get, :post, :put, :patch, :delete, etc.)

    Returns:

    • (Symbol)

      The set verb

  • #verbSymbol

    Gets the current HTTP verb

    Returns:

    • (Symbol)

      Current verb



490
491
492
493
# File 'lib/steppe/endpoint.rb', line 490

def verb(vrb = nil)
  @verb = vrb if vrb
  @verb
end