Skip to content

Latest commit

 

History

History
828 lines (650 loc) · 30.5 KB

File metadata and controls

828 lines (650 loc) · 30.5 KB

Contributing

This document defines the principles and workflow for creating and maintaining code examples in this repository.

Setup

Prerequisites

You need the following installed locally:

  • Python 3.x — runs the seed script, Python tests, and the spec directory generator
  • Node.js 18+ and npm — runs JavaScript tests
  • Docker — runs Ruby, PHP, and Java tests (via language-specific Docker images)
  • Go 1.21+ — runs Go tests locally (no Docker)
  • .NET 8 SDK — runs C# tests locally (no Docker)

Initial environment setup

After cloning, create the Python virtual environment and install JavaScript dependencies:

python3 -m venv .venv
source .venv/bin/activate
pip install elasticsearch==9.3.0

npm install

The Go, Ruby, PHP, and Java environments are handled automatically by their test runner scripts (see Testing workflow below). Local artifacts like go.mod, composer.json, and build output directories are gitignored.

Data sources

  • schema.json — the Elasticsearch request/response specification. This is the canonical list of endpoints. Each endpoint has a name, and an availability object with stack and/or serverless entries, each containing an optional visibility field. This file is not checked in. You must provide it locally, e.g. by symlinking to a checkout of the elasticsearch-specification repository:

    ln -s /path/to/elasticsearch-specification/output/schema/schema.json schema.json
  • elastic-start-local/ — a local Elasticsearch server managed by the elastic-start-local tool. Contains a .env file with connection details. Key variables:

    • ES_LOCAL_URL — server URL (default: http://localhost:9200)
    • ES_LOCAL_PORT — HTTP port
    • ES_LOCAL_PASSWORD — password for the elastic user
    • ES_LOCAL_API_KEY — base64-encoded API key

    This directory is not checked in. You must provide it locally, e.g. by symlinking to an existing elastic-start-local installation:

    ln -s /path/to/elastic-start-local elastic-start-local

Verifying the server is running

Before testing, confirm Elasticsearch is reachable:

source elastic-start-local/.env
curl -s -u "elastic:$ES_LOCAL_PASSWORD" "$ES_LOCAL_URL"

If the server is not running, start it from the elastic-start-local/ directory following its own README. The server runs in Docker.

Client documentation

Use the official Elasticsearch client docs to confirm idiomatic usage:

Which endpoints to cover

An endpoint is public if its stack visibility is "public" or unset (public is the default). Only public endpoints get directories under examples/.

Endpoints with stack.visibility set to "private" or "feature_flag" are excluded. Serverless visibility is not considered.

To regenerate the directory structure from the spec:

import json, os, shutil

with open("schema.json") as f:
    data = json.load(f)

for ep in data["endpoints"]:
    vis = ep.get("availability", {}).get("stack", {}).get("visibility")
    name = ep["name"]
    path = os.path.join("examples", name)
    if vis and vis != "public":
        if os.path.isdir(path):
            shutil.rmtree(path)
    else:
        os.makedirs(path, exist_ok=True)

File conventions

Paired endpoints

Some REST endpoints form a lifecycle pair (open/close, start/stop). When the close/clear/stop half only makes sense in context of the open/start half, collapse them into a single example directory named after the concept, not the verb:

REST endpoints Example directory
open_point_in_time + close_point_in_time point_in_time
scroll + clear_scroll scroll
async_search.submit/get/status/delete async_search

The endpoint field in front matter uses the directory name (e.g. point_in_time), not the individual REST endpoint names.

Filename

{endpoint}-{es_version}-{language}.md

The endpoint name, Elasticsearch version, and language appear in that order so files sort naturally by endpoint, then version, then language.

Front matter

Every file must start with YAML front matter containing exactly these four fields:

---
endpoint: bulk
lang: python
es_version: "9.3"
client: elasticsearch==9.3.0
---
  • endpoint — must match the directory name (usually the spec's name field, but may differ for paired endpoints; see above)
  • lang — lowercase language identifier
  • es_version — quoted major.minor string
  • client — package name and version as published to the language's registry (PyPI, npm, RubyGems, Packagist, Go modules)

The valid lang and client values for the current seven languages:

lang client
python elasticsearch==9.3.0
javascript "@elastic/elasticsearch@9.3.0"
ruby elasticsearch==9.3.0
php elasticsearch/elasticsearch==9.3.0
go github.com/elastic/go-elasticsearch/v9
java co.elastic.clients:elasticsearch-java:9.3.0
dotnet Elastic.Clients.Elasticsearch==9.3.0

Title

# Elasticsearch {version} {endpoint} endpoint ({Language} example)

The {endpoint} in the title must match the directory name, not the REST endpoint name. For paired endpoints this means using the concept name: point_in_time, not open_point_in_time.

Section structure

Every file begins with a main example, followed by additional sections that are consistent across all languages for a given endpoint. The sections should reflect the most important concerns for that endpoint.

  1. Main example (no heading) — a complete, working snippet showing the primary use case. Preceded by a one- or two-sentence description of what API or helper is being used. Followed by brief prose noting any optional fields or key design choices.

  2. Additional sections — two or three ##-level sections covering secondary concerns. These must use the same headings across all language variants of the same endpoint. Choose headings that fit the endpoint naturally.

For the bulk endpoint, the sections are:

  • ## Handling errors
  • ## Large datasets

Other endpoints will have different sections. For example, search might use ## Pagination and ## Filtering and aggregations. When creating examples for a new endpoint, define the sections with the first language file, then replicate them consistently.

Writing principles

Audience and context

Examples are consumed by developers who already have an application and a connected Elasticsearch client. They need a snippet to integrate, not a tutorial.

  • Assume connectivity is established. Reference client as an already-connected instance. Do not show connection setup.
  • Minimal boilerplate. No unnecessary imports, no wrapper functions, no main entrypoints unless the language requires it (e.g., Go).
  • Real-world data. Use realistic field names and values (e.g., a product catalog), not placeholder data like {"foo": "bar"}.
  • Screen-sized. Each file should fit roughly on one screen when rendered as HTML. Some may go longer; that's fine.

Code quality

  • Idiomatic first, consistent second. A developer immersed in a given language's ecosystem should find the code completely natural. Idiomatic style for the target language always takes priority over cross-language consistency. Specifically:

    • Static languages must use typed models. Java, Go, and C# code must define proper types (records, structs, classes) for documents. Never use untyped maps or dictionaries (e.g., Map<String, Object> in Java) to hold document data — that is a dynamic-language pattern and looks alien in static-language code. If you find yourself reaching for a map/dictionary where the language has type definitions, you are writing Python in another language's syntax.

    • Use each language's native idioms. Java uses records and the builder/fluent pattern. Go uses structs with JSON tags and explicit error handling. C# uses records, async/await, pattern matching, and LINQ where appropriate. Ruby uses symbols and blocks. PHP uses associative arrays (which are idiomatic for PHP, unlike Java maps).

    • Use the right helper for each client. Use helpers.bulk in Python, client.helpers.bulk in JavaScript, esutil.BulkIndexer in Go, BulkIngester in Java, BulkAll in .NET. Use the standard API when the client has no higher-level abstraction (e.g., Ruby, PHP).

    A good test: show the snippet to a practitioner of that language. If they would rewrite it before putting it in their codebase, the example has failed.

  • Consistent across languages where it doesn't conflict with idiom. Use the same sample data, the same index name ("products"), and the same document IDs ("prod-1", etc.) wherever possible. Section names, front matter fields, and prose structure must match across all language variants. But never sacrifice idiomatic code for the sake of making two files look similar.

  • Tested. Every code snippet in the main example and error handling sections must be runnable against the local Elasticsearch server. Test before committing. For languages not installed locally, use Docker (see Testing workflow below).

Code style per language

These conventions are already established in the existing examples. Follow them so new files don't drift.

Language Quotes Semicolons Client variable Notes
Python Double N/A client
JavaScript Double Yes client async/await, const over let
Ruby Single (double for #{}) N/A client Symbols for keys, puts over print
PHP Single (double for $var) Yes $client echo with \n
Go Double (Go standard) N/A client Typed API, pointer helpers, log.Fatalf for errors
Java Double (Java standard) Yes client Records for documents, var for locals, fluent builders
.NET Double (C# standard) Yes client Records for documents, async/await, Console.WriteLine

Embedded syntax and quote flipping. Strings containing JSON, Mustache templates, EQL, or ES|QL often embed double quotes as part of their content. In Python, Ruby, and PHP, use the other quote style to avoid escaping:

# Python: single quotes because the JSON content contains double quotes
source='{"query": {"match": {"{{field}}": "{{value}}"}}}'
# Ruby: single quotes are already the default, so JSON content is natural
source: '{"query": {"match": {"{{field}}": "{{value}}"}}}'
// PHP: single quotes are the default; JSON content contains double quotes
'source' => '{"query": {"match": {"{{field}}": "{{value}}"}}}'

When the string has no embedded quotes, use the default for the language. The key rule: never escape quotes when flipping avoids it.

For PHP echo with concatenation, keep literal text in single quotes and only use double quotes for escape sequences like "\n":

echo 'Quality score: ' . round($score, 3) . "\n";

The _connection example

The examples/_connection/ directory is a special case. It is the only example that intentionally shows connection setup — client instantiation, authentication, and connection options. It also includes instructions for installing the client library in each language. Its title uses "connection management" instead of "endpoint":

# Elasticsearch 9.3 connection management ({Language} example)

All other examples assume client is already connected and must not include setup code.

Per-directory README files

Each endpoint directory contains a README.md that lists and links to all language variants. When adding a new endpoint or a new language, update the relevant README.md files to include the new entries.

What to avoid

  • Do not include connection setup or client instantiation (except in _connection).
  • Do not add comments that narrate what the code does.
  • Do not use toy data or meaningless field names.
  • Do not show refresh or other operational parameters in the main example unless the endpoint specifically requires them.
  • Keep section structure parallel across languages for a given endpoint. The exception is client helper sections (e.g. "Using helpers" in scroll) — these only appear in languages whose client provides a higher-level abstraction and may be absent from others.

Testing workflow

Test runner scripts

Every language has a test runner script in scripts/ that handles credentials, virtual environments, and Docker boilerplate:

scripts/test-python.sh my_test.py
scripts/test-javascript.sh my_test.js
scripts/test-ruby.sh my_test.rb       # Docker: ruby:3.3
scripts/test-php.sh my_test.php       # Docker: composer:latest
scripts/test-go.sh my_test.go         # local Go, temp module
scripts/test-java.sh MyTest.java      # Docker: maven:3.9-eclipse-temurin-17
scripts/test-dotnet.sh Program.cs     # local .NET SDK, temp project

Seeding test data

Before testing, seed the standard product data:

source .venv/bin/activate
python scripts/seed.py          # seed 4 products into 'products' index
python scripts/seed.py --clean  # delete all test indices

This creates the products index with 4 documents (prod-1 through prod-4) that all examples use. Run it before each test session. The script reads credentials from elastic-start-local/.env automatically.

The product schema has these fields — reference them consistently in all examples:

ID name brand price category in_stock rating
prod-1 Espresso Machine Pro BrewMaster 899.99 appliances true 4.7
prod-2 Noise-Cancelling Headphones SoundCore 249.00 electronics true 4.5
prod-3 Ergonomic Standing Desk DeskCraft 599.00 furniture false 4.8
prod-4 4K Webcam with Mic StreamGear 129.99 electronics true 4.3

Java project template

A reusable pom.xml lives in testing/java/. The test-java.sh script uses it automatically. For manual testing, see testing/java/README.md.

Verification checklist

After creating or editing example files:

  1. Test the main example code against the running server
  2. Test the error handling code against the running server
  3. Verify front matter has exactly four fields with correct values
  4. Verify the title follows the format
  5. Verify section headings match across all languages for the endpoint
  6. Compare structure against existing examples in other languages

Language-specific v9 API reference

The Elasticsearch v9 clients vary significantly in their API surface. This section documents the tested, correct patterns for each language. These patterns were verified against Elasticsearch 9.3 and should be the starting point for all new examples.

Python

Straightforward. Methods match endpoint names. Bodies are plain dicts.

from elasticsearch import Elasticsearch
client = Elasticsearch("http://localhost:9200", basic_auth=("elastic", "password"))

# CRUD
client.index(index="products", id="prod-1", document={...})
client.get(index="products", id="prod-1")
client.delete(index="products", id="prod-1")
client.update(index="products", id="prod-1", script={...})

# Multi-document
client.mget(index="products", ids=["prod-1", "prod-2"])
client.delete_by_query(index="products", query={...})
client.update_by_query(index="products", query={...}, script={...})
client.reindex(source={"index": "src"}, dest={"index": "dst"})

# Source filtering
client.mget(index="products", ids=[...], source_includes=["name", "price"])

JavaScript

Async methods. camelCase names. Plain objects for bodies.

const { Client } = require('@elastic/elasticsearch')
const client = new Client({ node: 'http://localhost:9200', auth: {...} })

// CRUD
await client.index({ index: 'products', id: 'prod-1', document: {...} })
await client.get({ index: 'products', id: 'prod-1' })
await client.delete({ index: 'products', id: 'prod-1' })

// Multi-document
await client.mget({ index: 'products', ids: [...] })
await client.deleteByQuery({ index: 'products', query: {...} })
await client.updateByQuery({ index: 'products', query: {...}, script: {...} })
await client.reindex({ source: { index: 'src' }, dest: { index: 'dst' } })

Ruby

Symbol keys for parameters. String keys in response hashes. Body goes in body: parameter.

client = Elasticsearch::Client.new(url: "...", user: "...", password: "...")

# CRUD
client.index(index: 'products', id: 'prod-1', body: {...})
client.get(index: 'products', id: 'prod-1')

# Multi-document — note body wrapping
client.mget(index: 'products', body: { ids: [...] })
client.delete_by_query(index: 'products', body: { query: {...} })
client.update_by_query(index: 'products', body: { query: {...}, script: {...} })
client.reindex(body: { source: { index: 'src' }, dest: { index: 'dst' } })

PHP

Associative arrays for everything. Method names are camelCase.

$client = ClientBuilder::create()->setHosts([...])->build();

// CRUD
$client->index(['index' => 'products', 'id' => 'prod-1', 'body' => [...]]);
$client->get(['index' => 'products', 'id' => 'prod-1']);

// Multi-document
$client->mget(['index' => 'products', 'body' => ['ids' => [...]]]);
$client->deleteByQuery(['index' => 'products', 'body' => ['query' => [...]]]);
$client->reindex(['body' => ['source' => [...], 'dest' => [...]]]);

// Empty objects: use new \stdClass() for match_all etc.
$client->deleteByQuery([..., 'body' => ['query' => ['match_all' => new \stdClass()]]]);

Go (typed API)

Uses the typed client with builder-style chaining. Pointer-heavy. Import paths are deep.

import (
    "github.com/elastic/go-elasticsearch/v9"
    "github.com/elastic/go-elasticsearch/v9/typedapi/core/mget"
    "github.com/elastic/go-elasticsearch/v9/typedapi/types"
    "github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/conflicts"
)

client, _ := elasticsearch.NewTypedClient(elasticsearch.Config{...})

// CRUD
client.Index("products").Id("prod-1").Request(nil).Body(product).Do(ctx)
client.Get("products", "prod-1").Do(ctx)
client.Delete("products", "prod-1").Do(ctx)

// Multi-document
client.Mget().Index("products").Request(&mget.Request{
    Ids: []string{"prod-1", "prod-2"},
}).Do(ctx)

// Queries use maps
query := &types.Query{
    Term: map[string]types.TermQuery{
        "category": {Value: "electronics"},
    },
}

// Script params: use json.RawMessage, NOT map[string]interface{}
import "encoding/json"
params := map[string]json.RawMessage{
    "discount": toRawMessage(0.9),
}
func toRawMessage(v interface{}) json.RawMessage {
    b, _ := json.Marshal(v)
    return b
}

// Float64 pointers (for range queries etc.)
lt := types.Float64(4.0)
types.NumberRangeQuery{Lt: &lt}

// Conflicts enum
client.DeleteByQuery("products").Conflicts(conflicts.Proceed)

Java (v9 client)

Fluent builder pattern. Records for typed documents. The v9 client API is significantly different from v7/v8.

// Connection — v9 uses ElasticsearchClient.of(), NOT RestClientTransport
var client = ElasticsearchClient.of(b -> b
    .host("http://localhost:9200")
    .usernameAndPassword("elastic", "password")
);

// Typed documents — use records, not Map<String, Object>
public record Product(String name, double price, String category) {}

// CRUD
client.index(i -> i.index("products").id("prod-1").document(product));
client.get(g -> g.index("products").id("prod-1"), Product.class);
client.delete(d -> d.index("products").id("prod-1"));

// Multi-document
client.mget(m -> m.index("products").ids(List.of("prod-1", "prod-2")), Product.class);
client.deleteByQuery(d -> d
    .index("products")
    .query(q -> q.term(t -> t.field("category").value("electronics")))
);
client.reindex(r -> r
    .source(s -> s.index("products"))
    .dest(d -> d.index("products-v2"))
);

// Scripts — v9 uses .source(src -> src.scriptString(...)), NOT .inline(...)
client.updateByQuery(u -> u
    .index("products")
    .query(q -> q.term(t -> t.field("category").value("electronics")))
    .script(s -> s
        .source(src -> src.scriptString("ctx._source.price *= params.discount"))
        .params("discount", JsonData.of(0.9))
    )
);

.NET (v9 client)

Async/await. Fluent lambda builders. Records for typed documents.

// Connection
var client = new ElasticsearchClient(new ElasticsearchClientSettings(
    new Uri("http://localhost:9200"))
    .Authentication(new BasicAuthentication("elastic", "password"))
);

// Typed documents
public record Product(string Name, double Price, string Category);

// CRUD
await client.IndexAsync(product, i => i.Index("products").Id("prod-1"));
await client.GetAsync<Product>("prod-1", g => g.Index("products"));
await client.DeleteAsync("products", "prod-1");

// Multi-document
await client.MultiGetAsync<Product>(m => m
    .Index("products")
    .Ids(new Ids(new[] { "prod-1", "prod-2" }))
);
await client.DeleteByQueryAsync<object>("products", d => d
    .Query(q => q.Term(t => t.Field("category").Value("electronics")))
);
await client.ReindexAsync(r => r
    .Source(s => s.Index("products"))
    .Dest(d => d.Index("products-v2"))
);

// Scripts
await client.UpdateByQueryAsync("products", u => u
    .Query(q => q.Term(t => t.Field("category").Value("electronics")))
    .Script(s => s
        .Source("ctx._source.price *= params.discount")
        .Params(p => p.Add("discount", 0.9))
    )
);

Known gotchas

Hard-won lessons from building and testing examples across all seven languages. Read this before starting work.

Java

  • v9 client connection is completely different from v7/v8. Do not use RestClientTransport, RestHighLevelClient, or Apache HTTP client classes. The v9 pattern is ElasticsearchClient.of(b -> b .host(...).usernameAndPassword(...)).
  • Scripts and templates use .source(src -> src.scriptString(...)), not .source("string"). The source() method on Script, SearchTemplate, and RenderSearchTemplate builders takes a ScriptSource, not a raw string. This is the most common compilation error.
  • The Maven artifact is co.elastic.clients:elasticsearch-java. The latest 9.x version on Maven Central at time of writing is 9.0.2 (the version tracks the client library, not the server).
  • Java records need @JsonProperty for snake_case fields. The seed data stores in_stock as snake_case, but Java records use inStock. Add @JsonProperty("in_stock") from com.fasterxml.jackson.annotation (needs jackson-annotations as a compile dependency; already in the testing pom.xml).
  • Java tests via Docker are slow (~60s for compile + run). Budget time accordingly. Use timeout 120 in CI to avoid hangs.
  • Java ES|QL uses ObjectsEsqlAdapter and ResultSetEsqlAdapter, not the raw query() method. The raw method returns a BinaryResponse. The ObjectsEsqlAdapter lives at co.elastic.clients.elasticsearch._helpers.esql.objects. Use KEEP in ES|QL queries to avoid .keyword subfields that won't map to record fields.
  • Some response objects implement Map directly. For example, GetAliasResponse and GetMappingResponse are themselves Map<String, ...> — iterate with response.forEach(...) or access entries with response.get("indexName"). They do not have a .result() method.

Go

  • Script params must be map[string]json.RawMessage, not map[string]interface{}. This is a compile-time error that is easy to miss. Define a helper:
    func toRawMessage(v interface{}) json.RawMessage {
        b, _ := json.Marshal(v)
        return b
    }
  • types.Float64 is a named type, not a pointer. For range queries, you need &lt where lt := types.Float64(4.0). Writing types.Float64(4.0) directly where *types.Float64 is expected will not compile.
  • Import paths are deep. Every typed API request type has its own package: typedapi/core/mget, typedapi/core/deletebyquery, etc. Enums like conflicts.Proceed live under typedapi/types/enums/conflicts.
  • Hit.Id_ is *string in search results. Dereference with *hit.Id_. Note that GetResult.Id_ (from mget) is plain string.
  • The Scroll() method on Scroll takes DurationVariant, not a string. Use Request(&scroll.Request{ScrollId: id, Scroll: "1m"}) instead of method chaining for the scroll follow-up call.
  • The typed API sometimes requires pointer fields. Use small helper functions like strPtr and intPtr rather than inline address-of expressions.
  • Methods like Metric() take a single comma-separated string, not variadic arguments. Write Metric("docs,store"), not Metric("docs", "store").
  • PutSettings() does not have an Index() method on the builder chain. Pass the index via Request() or use the appropriate endpoint path.

.NET

  • Method names don't always match the REST endpoint. mget is MultiGetAsync, delete_by_query is DeleteByQueryAsync, etc.
  • Ids takes a specific type, not a plain string array: use new Ids(new[] { "prod-1", "prod-2" }).
  • Response property names don't follow a single pattern. The property holding the main result varies by endpoint: GetAliasResponse.Aliases, GetMappingResponse.Mappings, GetIndicesSettingsResponse.Settings, GetFieldMappingResponse.FieldMappings. Do not assume .Indices or .Result — check the actual type.
  • Some method parameters require specific enum types. For example, IndicesStatsRequest.Metric() takes CommonStatsFlag enum values (CommonStatsFlag.Docs, CommonStatsFlag.Store), not strings. ExistsAliasAsync takes a Names argument directly, not a lambda descriptor. Always check the method signature.

PHP

  • Empty query objects (like match_all) require new \stdClass(). Using an empty array [] serializes to [] in JSON, not {}.

Ruby and PHP

  • format in ES|QL is a query parameter, not a body field. Pass format: 'json' at the top level, not inside body:. Python and JavaScript accept it in either position; Ruby and PHP require it as a query parameter.

Ruby

  • The client prints a warning ("unable to verify that the server is Elasticsearch") when connecting to a local server without the product header. This is cosmetic and can be ignored during testing.
  • Query bodies go inside body:, unlike Python/JS where they are top-level parameters.

All languages

  • Always clean up test indices after tests. The test data seeder (scripts/seed.py --clean) handles the standard indices, but if your test creates additional indices (e.g., products-v2, electronics), delete them explicitly.
  • Remember to refresh between write and read operations in tests. Elasticsearch is near-real-time; a document indexed without a refresh may not appear in subsequent searches within the same test.

Priority guidance

When choosing which endpoints to cover next, prefer this order:

  1. Core document APIsindex, get, delete, update, mget, reindex (the CRUD fundamentals)
  2. Search APIssearch, msearch, scroll, count, field_caps (the most-used read path)
  3. Index managementindices.create, indices.delete, indices.get, indices.put_mapping, indices.put_settings
  4. Aggregations and analytics — endpoints commonly used for dashboards and reporting
  5. Cluster and node APIscluster.health, cluster.stats, cat.*
  6. Everything else — remaining public endpoints

Within each group, complete all seven languages for one endpoint before moving to the next endpoint. This ensures every covered endpoint has full language coverage.

Extending this repository

Adding a new language

  1. Install or Docker-enable the language runtime
  2. Install the official Elasticsearch client for that language
  3. Add a test runner script to scripts/
  4. Write the example following the conventions above
  5. Test all snippets against the running server
  6. Add the language to the table in README.md
  7. Update each endpoint's README.md to include the new language

Adding a new endpoint

  1. Create the example file in the existing examples/{endpoint}/ directory
  2. Start with one language, test it, then replicate across all supported languages
  3. Choose realistic sample data that makes sense for the endpoint
  4. Follow the section structure conventions
  5. Create a README.md in the endpoint directory linking to all language variants

Adding a new Elasticsearch version

  1. Create new files alongside existing ones (e.g., bulk-9.4-python.md next to bulk-9.3-python.md)
  2. Update code if the client API has changed
  3. Test against a server running the new version

Current progress

To see which endpoints have examples and which are still empty:

for dir in examples/*/; do
  count=$(find "$dir" -name "*.md" | wc -l)
  if [ "$count" -gt 0 ]; then
    echo "  [done]  $(basename "$dir") ($count files)"
  fi
done

echo ""
echo "Endpoints without examples:"
for dir in examples/*/; do
  count=$(find "$dir" -name "*.md" | wc -l)
  if [ "$count" -eq 0 ]; then
    echo "  $(basename "$dir")"
  fi
done | head -20
echo "  ..."