# Optional OpenSearch API (experimental v0.1)

This is an additional, read-only interface to the existing RustSearch bibliographic
index. It lives in this project and uses the same engine; it is not a second Koha
plugin. Existing native routes, Koha configuration and index schema are unchanged.
There is no OpenSearch or Elasticsearch server dependency.

The component is excluded from normal builds and disabled at runtime by default.
The bundled KPZ setup includes the API source, Swagger assets and optional build
support. Uploading a KPZ alone does not refresh the engine executable or enable
the API. Compatibility with Koha's Elasticsearch adapter has not been established.

## Build and opt in locally

From `rustsearch-engine`, build the optional component explicitly:

```sh
cargo build --features opensearch-api --bin koha-rustsearch
```

To run a disposable development instance (use a new directory):

```sh
RUSTSEARCH_INDEX_PATH=/tmp/rustsearch-api-preview-index RUSTSEARCH_OPENSEARCH_ENABLED=1 ./target/debug/koha-rustsearch
```

The existing native listener defaults to `127.0.0.1:7888`. The additional listener
defaults to `127.0.0.1:7889`; override it with
`RUSTSEARCH_OPENSEARCH_BIND_ADDR=127.0.0.1:7890`. Both require loopback addresses.
An API address alone never enables the component. In a feature-enabled build,
`RUSTSEARCH_OPENSEARCH_ENABLED` accepts only `0` or `1`. Omit it or set it to `0`
to disable the listener. A normal build excludes the listener entirely.

The API shares the active engine's readers and receives updates made through native
indexing routes. Do not run a second engine process against an index already open
for writing by another engine. No index migration is introduced.

## Existing schema-7 installations

Update the existing plugin in place; do not uninstall it. Run its bundled server
setup with `INSTANCE --update --with-opensearch-api` to build the API into the
replacement engine while preserving the current indexes and native routes. The
listener remains disabled. The updater also preserves compiled API support when
it detects an already-enabled API on the selected instance's service.

For a standalone portable build, use
`python3 engine/scripts/build_native.py --with-opensearch-api`. Build metadata
records the selected features and hashes all embedded API assets.

To enable the listener after installing a feature-enabled engine, add an explicit
systemd drop-in for that instance's RustSearch service:

```ini
[Service]
Environment=RUSTSEARCH_OPENSEARCH_ENABLED=1
Environment=RUSTSEARCH_OPENSEARCH_BIND_ADDR=127.0.0.1:7889
```

Then reload systemd and restart that service. Use an unused loopback port for each
instance. Disable it by setting `RUSTSEARCH_OPENSEARCH_ENABLED=0` and restarting.
This does not change Koha's SearchEngine preference or OPAC enable flag. Keep
production engines and indexes on schema 7 or newer.

## Endpoints

| Endpoint on the additional listener | Purpose |
| --- | --- |
| `GET /opensearch/v1/` | RustSearch identity and supported capabilities |
| `GET/POST /opensearch/v1/biblios/_search` | Search the native bibliographic index |
| `GET/POST /opensearch/v1/biblios/_count` | Exact matching count |
| `GET /opensearch/v1/openapi.json` | OpenAPI 3.0.3 specification |
| `GET /opensearch/v1/docs` | Interactive Swagger UI |

Swagger assets are vendored and embedded in the feature-enabled binary. No CDN,
external schema validator or internet connection is needed to view the UI. Upstream
version, license, notice and file hashes are in `api/swagger-ui/`.

Elasticsearch has no implemented namespace. `/elasticsearch/v1/...` returns an
unsupported-endpoint error. Any future Elasticsearch interface must be versioned
and tested separately; this component makes no blanket Elasticsearch compatibility
claim. The API identifies itself as RustSearch, not an OpenSearch cluster.

## Supported contract

Validated with `opensearch-py==3.2.0` for the operations below, not the client's
entire surface. Configure its `url_prefix` to `opensearch/v1`:

```python
from opensearchpy import OpenSearch

client = OpenSearch(hosts=[{
    "host": "127.0.0.1", "port": 7889, "url_prefix": "opensearch/v1"
}])
result = client.search(index="biblios", body={
    "_source": False,
    "query": {"match": {"title": "horses"}},
    "stored_fields": ["title", "author"],
    "size": 10
})
```

- Queries: `match_all`, `match_none`, `match`, `match_phrase`, `term`, `ids`, `bool`.
- `match` and `match_phrase` support `title`, `author`, `subject`, `keyword_extra`.
  `match` accepts a string or `{query, operator}`; operator defaults to `or` and
  accepts `and`. `match_phrase` accepts a string or `{query}`. They use RustSearch's
  native Unicode folding analyzer, not an emulated OpenSearch analyzer.
- `term` is a literal, unanalyzed indexed term on those text fields, `isbn`, or
  numeric `biblionumber`. It accepts a value or `{value}`. `ids.values` accepts
  at most 256 positive bibliographic IDs, as integers or decimal strings.
- `bool` supports object or array forms of `must`, `filter`, `must_not`, `should`.
  Filters do not contribute score. `minimum_should_match` supports nonnegative
  integers up to the number of should clauses; percentages are unsupported.
  Its default is 1 when there are should clauses and no must/filter clauses,
  otherwise 0. Empty/negative-only boolean queries start with all records.
- Search `from` defaults to 0, `size` to 10. Size accepts 0..100 and
  `from + size` must not exceed 10,000. Totals are exact. Scores are native
  Tantivy relevance scores, not a promise of identical OpenSearch rankings.
- Original JSON source is not retained by the existing index. Requests must
  explicitly set `_source: false`. `stored_fields` optionally returns arrays of
  stored `biblionumber`, `title`, `author`, `subject`, `isbn`, `keyword_extra`.
  No original `_source` is invented. Hits contain `_index`, string `_id`, `_score`.
- GET search accepts URL `from`, `size`, `_source`, `track_total_hits`; POST
  accepts those same options in its body. Repeating an option across body and URL
  fails. Use POST for query DSL requests. Only `track_total_hits: true` is supported.
- Count accepts an optional body containing only `query`; GET with no body counts
  all bibliographic records.
- Query depth is limited to 16, nodes to 256, analyzed tokens to 64, input text to
  4096 bytes and HTTP request bodies to 1 MiB.

Unsupported options fail explicitly. Writes/bulk ingestion, original source
retrieval, custom mappings, aliases, aggregations/facets, sort, query strings,
multisearch, scroll, authority indexes and cluster administration are not part of
v0.1. This is not a drop-in replacement for Koha's Elasticsearch adapter.

The listener is for trusted local integrations. It has no authentication and does
not apply Koha patron/staff permissions, OPAC suppression or per-user visibility
filters. Do not expose it as a public catalog endpoint. The native Koha adapter
continues to apply its existing visibility policy independently.

## Verification

Install the isolated test dependencies from `tests/requirements-opensearch.txt`.
Then, from the repository root:

```sh
cargo test --manifest-path rustsearch-engine/Cargo.toml --bin koha-rustsearch --features opensearch-api
cargo build --manifest-path rustsearch-engine/Cargo.toml --bin koha-rustsearch --features opensearch-api
python3 rustsearch-engine/tests/opensearch_api.py rustsearch-engine/target/debug/koha-rustsearch
```

The HTTP regression creates temporary indexes, uses the real OpenSearch client,
validates OpenAPI, checks default-off behavior, errors and native-route isolation,
and tears down its processes. No Koha database or production service is used.
