Skip to content
Search

ETL Adapter: HTTP

The HTTP adapter sends HTTP requests and reads the responses as rows in ETL (Extract, Transform, Load) pipelines.

Installation

For the installation instructions, read the installation page.

Which extractor to use

  • from_http_paginated(): reads from an API. Start here. It covers all common pagination schemes.
  • from_static_http_requests(): a fixed list of URLs that you know before the run, no pagination.
  • from_dynamic_http_requests(): the last option, for a next request that no built-in paginator can describe.

Row structure

All extractors in this adapter emit the same columns, so the sections below apply to all three.

Column Type Description
response_body ?string Raw response body text
response_headers map<string, list<string>> Response headers, one list of values per header name
response_status_code integer HTTP status code, for example 200
response_protocol_version string for example 1.1
response_reason_phrase string for example OK
request_body ?string Raw body of the request that produced this response
request_uri string Full URI the request was sent to, query string included
request_headers map<string, list<string>> Request headers, one list of values per header name
request_protocol_version string for example 1.1
request_method string for example GET

Body handling

response_body and request_body hold the raw body text, for each content type. An empty or unreadable body becomes null. The adapter never decodes the body into the row. To type it as a structure, declare a schema (read Typing the row with a schema below).

Paginators decode the body internally, by its content type, to find records_path and the cursor fields. JSON and XML therefore paginate through the same DSL. That decoding never reaches the row columns.

Extractor - PsrHttpClientPaginatedExtractor

from_http_paginated($client, $baseRequest, $paginator, ?$schema) paginates declaratively. You do not write a NextRequestFactory. A paginator is a strategy (how to get the next page) + a stop condition. Strategies that inject a token also take an injection (where the token goes). The extractor stops on the safe default of the strategy, on HTTP >= 400 (the row is still emitted), and on a repeated request (loop guard). All http_* helpers below are in Flow\ETL\Adapter\Http.

<?php

use Http\Client\Curl\Client;
use Nyholm\Psr7\Factory\Psr17Factory;

use function Flow\ETL\Adapter\Http\{from_http_paginated, http_pagination_page_number, http_request_option_query};
use function Flow\ETL\DSL\{data_frame, to_output};

$psr17Factory = new Psr17Factory();
$psr18Client = new Client($psr17Factory, $psr17Factory);

data_frame()
    ->read(from_http_paginated(
        $psr18Client,
        $psr17Factory
            ->createRequest('GET', 'https://api.example.com/items')
            ->withHeader('Accept', 'application/json'),
        http_pagination_page_number(
            http_request_option_query('page'),
            page_size: 100,
            size_option: http_request_option_query('per_page'),
            records_path: 'items',
        ),
    ))
    ->write(to_output())
    ->run();

This goes through ?per_page=100&page=1,2,3... until the items path of a response is empty.

Supported strategies

// page number - ?per_page=100&page=1,2,3...          default stop: empty page at records_path
http_pagination_page_number(http_request_option_query('page'),
    page_size: 100, size_option: http_request_option_query('per_page'), records_path: 'items');

// offset / limit - ?limit=100&offset=0,100,200...    default stop: total_path reached, else empty page
http_pagination_offset(http_request_option_query('offset'), http_request_option_query('limit'),
    limit: 100, total_path: 'meta.total');

// cursor in body - ?cursor=<meta.next_cursor>      default stop: cursor missing
http_pagination_cursor('meta.next_cursor', http_request_option_query('cursor'));

// next URL in body - follows the URL (\. escapes a literal dot, e.g. OData @odata.nextLink)
http_pagination_next_url('@odata\.nextLink');

// Link header (RFC 8288) - follows rel="next", stops when absent
http_pagination_link_header('next');

// last record cursor - ?since=<last of data.*.id>
http_pagination_last_record_cursor('data.*.id', http_request_option_query('since'),
    stop_when: http_stop_when_flag_false('has_more'));

Injection - where the token goes (http_request_option_*)

http_request_option_query('page');                  // set/override a query param
http_request_option_header('X-Cursor');             // set a header
http_request_option_body('cursor', $psr17Factory);  // set a JSON body path, re-encoding the body (e.g. POST search)
http_request_option_uri();                          // the token *is* the next URL, resolved relative to the base

Stop conditions - http_stop_when_*, composed with http_stop_when_any_of() / http_stop_when_all_of()

// path_missing · empty_path · flag_false · flag_true · total_reached · max_pages · max_results
stop_when: http_stop_when_any_of(http_stop_when_flag_false('has_more'), http_stop_when_max_pages(1000));

Extractor - PsrHttpClientStaticExtractor

from_static_http_requests($client, $requests, ?$schema) sends a predefined collection of requests. It is for URLs that you know before the run. No request depends on a response.

<?php

use Http\Client\Curl\Client;
use Nyholm\Psr7\Factory\Psr17Factory;

use function Flow\ETL\Adapter\Http\from_static_http_requests;
use function Flow\ETL\DSL\{data_frame, to_output};

$psr17Factory = new Psr17Factory();
$psr18Client = new Client($psr17Factory, $psr17Factory);

$requests = function () use ($psr17Factory) : \Generator {
    foreach (['norberttech', 'tomaszhanc'] as $username) {
        yield $psr17Factory
            ->createRequest('GET', 'https://api.github.com/users/' . $username)
            ->withHeader('Accept', 'application/vnd.github.v3+json')
            ->withHeader('User-Agent', 'flow-php/etl');
    }
};

data_frame()
    ->read(from_static_http_requests($psr18Client, $requests()))
    ->write(to_output())
    ->run();

Each response is one row and, by default, one batch. Group responses into batches with withBatchSize(). A pushed limit stops the requests at the limit:

<?php

data_frame()
    ->read(from_static_http_requests($psr18Client, $requests())->withBatchSize(50))
    ->limit(10) // 10 requests sent
    ->write(to_output())
    ->run();

Extractor - PsrHttpClientDynamicExtractor

Use this only for an API that no built-in paginator fits. It is the lowest-level extractor of the three. You write the request loop yourself, so the loop guards, stop conditions and token extraction are your task. If the API paginates in one of the shapes above, use from_http_paginated() instead.

from_dynamic_http_requests($client, $requestFactory, ?$schema) runs the loop through a NextRequestFactory. The factory receives the previous response and returns the next request, or null to stop.

<?php

use Flow\ETL\Adapter\Http\IO\Extractor\NextRequestFactory;
use Http\Client\Curl\Client;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\{RequestInterface, ResponseInterface};

use function Flow\ETL\Adapter\Http\from_dynamic_http_requests;
use function Flow\ETL\DSL\{data_frame, to_output};

$psr17Factory = new Psr17Factory();
$psr18Client = new Client($psr17Factory, $psr17Factory);

data_frame()
    ->read(from_dynamic_http_requests($psr18Client, new class($psr17Factory) implements NextRequestFactory {
        public function __construct(private readonly Psr17Factory $psr17Factory)
        {
        }

        public function create(?ResponseInterface $previousResponse = null) : ?RequestInterface
        {
            if ($previousResponse !== null) {
                return null;
            }

            return $this->psr17Factory
                ->createRequest('GET', 'https://api.github.com/orgs/flow-php')
                ->withHeader('Accept', 'application/vnd.github.v3+json')
                ->withHeader('User-Agent', 'flow-php/etl');
        }
    }))
    ->write(to_output())
    ->run();

Typing the row with a schema

All three extractors have withSchema(Schema) (also the optional last argument of the from_* DSL functions). The schema describes the row. To type the body, declare response_body as a structure. As with all extractors, only the columns in the schema stay, so add the envelope columns that you want to keep.

<?php

use function Flow\ETL\Adapter\Http\{from_http_paginated, http_pagination_cursor, http_request_option_query};
use function Flow\ETL\DSL\{int_schema, schema, structure_schema};
use function Flow\Types\DSL\{type_integer, type_string, type_structure};

$extractor = from_http_paginated(
    $psr18Client,
    $psr17Factory->createRequest('GET', 'https://api.example.com/items'),
    http_pagination_cursor('meta.next_cursor', http_request_option_query('cursor')),
)->withSchema(schema(
    structure_schema('response_body', type_structure([
        'id' => type_integer(),
        'name' => type_string(),
    ])),
    int_schema('response_status_code'),
));

The OpenAPI specification bridge can make the response_body structure from an OpenAPI specification. schema_from_openapi_specification($spec) returns the field definitions of the body. The HTTP adapter therefore needs no extra dependency.

Found a typo or an outdated section? Edit this page on GitHub


Contributors

Built in the open.

Join us on GitHub
scroll back to top