Rest API Design for OpenSearch Integration
Portal Search: REST API Reference
Author: @Bryan Fauble
- 1 Portal Search: REST API Reference
- 2 Overview
- 2.1 Resources
- 2.2 Design Principles
- 3 Quick Start
- 4 Endpoint Summary
- 5 Security & Authorization
- 6 Data Model
- 6.1 SynonymSet
- 6.2 SynonymRule
- 6.3 ColumnAnalyzerOverride
- 6.4 ColumnAnalyzerOverrideEntry
- 6.5 TextAnalyzer
- 6.6 TextAnalyzerSettings
- 6.7 SearchConfiguration
- 6.8 SearchIndex
- 6.9 SearchIndexState
- 6.10 SearchQuery
- 6.11 SearchResults
- 6.12 SearchHit
- 6.13 FacetColumnResult (reused)
- 7 Enums
- 8 Filter & Sort Types
- 8.1 KeyValue (reused)
- 8.2 KeyRange (reused)
- 8.3 KeyValues
- 8.4 FacetRequest
- 8.5 SortField
- 9 List Request/Response Pattern
- 10 Configuration Notes
- 10.1 SearchConfiguration - Composition and Resolution
- 10.2 Project Settings Integration (Fallback Inheritance)
- 10.3 Defining SQL
- 10.4 Analyzer Configuration
- 10.4.1 Defaults by Column Type
- 10.4.2 Priority Order
- 10.4.3 Multi-Field Mapping
- 10.5 Synonym Configuration
- 10.5.1 Rule Types
- 10.5.2 Multi-Word Handling
- 10.5.3 Synonym Propagation
- 11 Endpoints
- 11.1 Synonym Sets
- 11.1.1 Create Synonym Set
- 11.1.2 Get Synonym Set
- 11.1.3 Update Synonym Set
- 11.1.4 Delete Synonym Set
- 11.1.5 List Synonym Sets
- 11.2 Column Analyzer Overrides
- 11.3 Text Analyzers
- 11.3.1 Get Text Analyzer
- 11.3.2 List Text Analyzers
- 11.4 Search Configurations
- 11.4.1 Create Search Configuration
- 11.4.2 Get Search Configuration
- 11.4.3 Update Search Configuration
- 11.4.4 Delete Search Configuration
- 11.4.5 List Search Configurations
- 11.5 Search Operations
- 11.5.1 Search Async Start
- 11.5.2 Search Async Get
- 11.5.3 Autocomplete
- 11.1 Synonym Sets
- 12 Query Examples
- 13 Error Responses
Overview
Portal Search provides REST endpoints for creating OpenSearch-backed search indexes over Synapse table-like entities and executing full-text search queries against them.
Resources
The API is organized around four standalone configuration resources under /repo/v1/search/* and one SearchIndex Synapse Entity managed via the standard entity controller under /repo/v1/entity/*.
All resources are logically scoped to an Organization (an existing Synapse concept — CRUD and ACL management at /schema/organizations/*), but note that SearchIndex does not have its own organizationId field; it is scoped via the standard entity parentId (Project or Folder).
Resource | Purpose |
|---|---|
SynonymSet | Reusable synonym rules (e.g., |
ColumnAnalyzerOverride | Per-column text analysis overrides. Cannot be deleted while referenced by a |
TextAnalyzer | A standalone reusable resource that defines how text is analyzed (tokenizer, filters, synonym awareness). Referenced by ColumnAnalyzerOverrideEntry ( |
SearchConfiguration | Bundles synonym sets and column overrides. References a default TextAnalyzer ( |
SearchIndex (Entity) | A Synapse Entity ( |
Resources compose as:
SearchIndex → SearchConfiguration → {SynonymSets[], ColumnAnalyzerOverrides[]}.
TextAnalyzer is a standalone resource referenced by ColumnAnalyzerOverrideEntry.indexAnalyzerId/searchAnalyzerId and SearchConfiguration.defaultAnalyzerId.
Design Principles
Organization-scoped authorization for configuration. The four configuration resources (
SynonymSet,ColumnAnalyzerOverride,TextAnalyzer,SearchConfiguration) are Organization-scoped and identified byorganizationId. They are publicly readable; mutations require Organization ACL permissions.SearchIndexis a standard Synapse Entity without its ownorganizationIdfield; it is scoped byparentId(Project or Folder), and its ACL follows normal entity rules.Secure search access. Search queries and autocomplete requests are not fully public. Callers must have:
READon theSearchIndexentity andSufficient permissions to read from the underlying table referenced by
definingSQL(mirrors the MaterializedView authorization pattern).
Build-once semantics with rebuild on update. AOSS indexes are point-in-time snapshots. There are no incremental updates. The lifecycle worker builds (or rebuilds) indexes on both CREATE and UPDATE of
SearchIndexentities.Single-entity constraint.
definingSQLmust reference exactly one entity. Multi-entity JOINs are rejected with 400 Bad Request.Shared resource protection.
SynonymSets andColumnAnalyzerOverrides cannot be deleted while referenced by anySearchConfiguration.Name uniqueness.
For
SynonymSet,ColumnAnalyzerOverride, andSearchConfiguration, names are unique per Organization:UNIQUE(organizationId, name).For
TextAnalyzer, system analyzer names are globally unique; user-defined analyzer names are unique per Organization.For
SearchIndex(as a Synapse Entity), name uniqueness follows the standard entity rules: unique within its parent container (parentId).
Single polling point. Search queries run as async jobs. If an index is still building, the worker automatically retries — the client only polls the async result endpoint.
Quick Start
Step 1: Create Supporting Resources
Create a synonym set in your Organization:
POST /repo/v1/search/synonym/set{
"organizationId": "42",
"name": "NF Disease Terms",
"description": "Synonym mappings for neurofibromatosis-related disease terminology",
"rules": [
{ "ruleType": "EQUIVALENT", "terms": ["NF1", "neurofibromatosis 1", "von Recklinghausen disease"] },
{ "ruleType": "EQUIVALENT", "terms": ["schwannoma", "vestibular schwannoma", "acoustic neuroma"] }
]
}Create a search configuration referencing the synonym set:
POST /repo/v1/search/configuration{
"organizationId": "42",
"name": "NF Portal Config",
"synonymSetIds": ["501"],
"defaultAnalyzerId": "1",
"columnAnalyzerOverrideIds": []
}Step 2: Create a Search Index Entity
SearchIndex is a Synapse Entity, not a standalone /search/index resource. Creation goes through the standard entity controller. Use the SearchIndex concrete type and provide a parentId (a Project or Folder):
POST /repo/v1/entity{
"concreteType": "org.sagebionetworks.repo.model.table.search.SearchIndex",
"parentId": "syn123",
"name": "Studies Search",
"definingSQL": "SELECT studyName, summary, diseaseFocus, species, assay FROM syn52694652",
"searchConfigurationId": "301"
}Sample Response (201 Created):
{
"id": "syn101",
"concreteType": "org.sagebionetworks.repo.model.table.search.SearchIndex",
"parentId": "syn123",
"name": "Studies Search",
"definingSQL": "SELECT studyName, summary, diseaseFocus, species, assay FROM syn52694652",
"searchConfigurationId": "301",
"versionNumber": 1,
"etag": "aaa-bbb-ccc",
"createdBy": "3350396",
"createdOn": "2026-02-18T12:00:00.000Z",
"modifiedBy": "3350396",
"modifiedOn": "2026-02-18T12:00:00.000Z"
}Note: Index state (
CREATING,ACTIVE,FAILED,DELETING) is stored in the indexing DB'sSEARCH_INDEX_STATUStable and is not a property of theSearchIndexentity itself.
The index build is handled asynchronously by the SearchIndexLifecycleWorker. Builds are triggered on both create and update of the SearchIndex entity.
Step 3: Submit a Search Query
Search operations remain under the /repo/v1/search/* namespace.
POST /repo/v1/search/query/async/start{
"searchIndexId": "syn101",
"queryText": "schwannoma gene expression",
"size": 10
}Response (201 Created):
{ "token": "98765" }Step 4: Poll for Results
Poll until the job completes. If the index is still building (CREATING), the worker automatically retries — the client only polls this endpoint.
GET /repo/v1/search/query/async/get/98765Response (200 OK when ready, 202 Accepted while processing):
{
"searchIndexId": "syn101",
"totalHits": 3,
"hits": [
{
"rowId": 42,
"score": 8.73,
"fields": {
"studyName": "Genomic Landscape of Schwannoma",
"diseaseFocus": "Schwannomatosis"
}
}
],
"facets": []
}Endpoint Summary
All URLs are prefixed with /repo/v1.
Configuration Resources
Method | URL | Auth | Description |
|---|---|---|---|
|
| CREATE on Organization | Create synonym set |
|
| Public | Get synonym set |
|
| UPDATE on Organization | Update synonym set |
|
| DELETE on Organization | Delete synonym set |
|
| Public | List synonym sets |
|
| CREATE on Organization | Create column analyzer override |
|
| Public | Get column analyzer override |
|
| UPDATE on Organization | Update column analyzer override |
|
| DELETE on Organization | Delete column analyzer override |
|
| Public | List column analyzer overrides |
|
| Public | Get text analyzer |
|
| Public | List text analyzers |
|
| CREATE on Organization | Create search configuration |
|
| Public | Get search configuration |
|
| UPDATE on Organization | Update search configuration |
|
| DELETE on Organization | Delete search configuration |
|
| Public | List search configurations |
Note:
SearchIndexCRUD (create, get, update, delete, trash/restore) is handled by the standard Synapse Entity API under/repo/v1/entity/*and is not enumerated above.SearchIndexbehaves like other entity types (e.g., Table, View) with additional search-specific fields.
Search Operations
Method | URL | Auth | Description |
|---|---|---|---|
|
| READ on SearchIndex and read access to source table | Start async search |
|
| Same as start | Get async results |
|
| READ on SearchIndex and read access to source table | Autocomplete |
Security & Authorization
Organization-Scoped ACL for Configuration Resources
Configuration resources (SynonymSet, ColumnAnalyzerOverride, SearchConfiguration) belong to an Organization (via organizationId). Authorization reuses the existing Organization ACL model (same as JSON Schemas):
ACL scope: Per-Organization (not per-resource).
Public read: All configuration resources are publicly readable (no auth for GET/list).
Mutating operations: Require appropriate
ACCESS_TYPEon the Organization ACL.Admin bypass: Admins skip ACL checks (
UserInfo.isAdmin()).Default ACL: When an Organization is created, the creator gets
{READ, CREATE, CHANGE_PERMISSIONS, UPDATE, DELETE}.
Operation | Required ACCESS_TYPE / Permissions |
|---|---|
Create configuration resource |
|
Get / List configuration resources | Public (no auth) |
Update configuration resource |
|
Delete configuration resource |
|
Manage Organization ACL |
|
TextAnalyzer endpoints are currently read-only (GET and LIST only) and publicly accessible. System analyzers are managed by the bootstrapper on startup; user-defined analyzer CRUD is not yet exposed via REST.
Data Plane: Search & Autocomplete
Actual behavior (mirrors MaterializedView authorization):
Search queries and autocomplete requests are not public.
To execute a search or autocomplete request, the caller must have:
READon theSearchIndexentity andSufficient permissions to read from the source table referenced by
definingSQL.
The implementation loads data as the anonymous user when building the OpenSearch index:
The
SearchIndexLifecycleWorkerusesTableQueryManager.runQueryAsStream()with the anonymous user.Only publicly accessible rows are indexed. Even if a caller has elevated permissions on the table, the underlying index contains only public rows; search results cannot leak non-public data.
Operation | Effective Authorization |
|---|---|
Execute search query | READ on |
Autocomplete | Same as search query |
Data Model
This section defines all JSON schema types used across the API. Configuration resources are regular REST objects stored in dedicated tables. SearchIndex is implemented as a Synapse Entity backed by NODE/NODE_REVISION, with a separate indexing DB holding status.
SynonymSet
{
"description": "A shared set of synonym rules. SynonymSets belong to an Organization and can be referenced by SearchConfigurations. Cannot be deleted while referenced.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string", "description": "Organization this resource belongs to." },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"rules": { "type": "array", "items": { "$ref": "#SynonymRule" } },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name", "rules"]
}SynonymRule
{
"description": "A single synonym rule.",
"properties": {
"ruleType": {
"type": "string",
"enum": ["EQUIVALENT", "EXPLICIT"],
"description": "EQUIVALENT (bidirectional) or EXPLICIT (one-way expansion)."
},
"terms": {
"type": "array",
"items": { "type": "string" },
"minItems": 2,
"description": "For EQUIVALENT: all terms are interchangeable. For EXPLICIT: first term maps to the rest."
}
},
"required": ["ruleType", "terms"]
}Rule Type | Behavior | Example |
|---|---|---|
| Bidirectional. All terms are interchangeable. |
|
| One-way. First term expands to the rest. |
|
ColumnAnalyzerOverride
{
"description": "A shared resource containing per-column analyzer override entries. ColumnAnalyzerOverrides belong to an Organization and can be referenced by SearchConfigurations. Cannot be deleted while referenced.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string" },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"overrides": { "type": "array", "items": { "$ref": "#ColumnAnalyzerOverrideEntry" } },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name", "overrides"]
}ColumnAnalyzerOverrideEntry
{
"description": "A per-column analyzer override entry. Specifies which analyzers to use at index and search time for a specific column.",
"properties": {
"columnName": { "type": "string", "description": "Must exist in the entity's schema." },
"indexAnalyzerId": {
"type": "string",
"description": "The ID of the TextAnalyzer to use when indexing this column."
},
"searchAnalyzerId": {
"type": "string",
"description": "The ID of the TextAnalyzer to use when searching this column."
}
},
"required": ["columnName", "indexAnalyzerId", "searchAnalyzerId"]
}TextAnalyzer
{
"description": "A database-stored text analyzer configuration. System analyzers have IDs 1-999 and are read-only; user-defined analyzers start at 1000+.",
"properties": {
"id": { "type": "string", "readOnly": true },
"name": { "type": "string", "description": "Unique within the organization (or globally for system analyzers)." },
"description": { "type": "string" },
"organizationId": { "type": "string", "description": "Null for system analyzers." },
"settings": { "$ref": "#TextAnalyzerSettings", "description": "The analyzer configuration." },
"isSystem": { "type": "boolean", "description": "True for system analyzers (read-only)." },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true }
}
}TextAnalyzerSettings
{
"description": "The OpenSearch analyzer configuration. Stores the full definition of how text is analyzed.",
"properties": {
"charFilters": { "type": "map(string, string)", "description": "Named character filter definitions. Values are JSON-serialized config objects." },
"tokenizer": { "type": "string", "description": "Tokenizer name (e.g., 'standard', 'whitespace', 'keyword')." },
"tokenizerConfig": { "type": "map(string, string)", "description": "Optional custom tokenizer config." },
"tokenFilters": { "type": "map(string, string)", "description": "Named token filter definitions. Values are JSON-serialized config objects." },
"filterOrder": { "type": "array", "items": { "type": "string" }, "description": "Ordered list of token filter names to apply." },
"charFilterOrder": { "type": "array", "items": { "type": "string" }, "description": "Ordered list of character filter names." },
"synonymAware": { "type": "boolean", "description": "Whether synonym filter should be appended when synonyms are configured." }
}
}SearchConfiguration
{
"description": "A reusable search configuration resource. References SynonymSets, ColumnAnalyzerOverrides, and a default text analyzer. Can be associated with projects via the Project Settings framework.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string" },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"synonymSetIds": { "type": "array", "items": { "type": "string" } },
"columnAnalyzerOverrideIds": { "type": "array", "items": { "type": "string" } },
"defaultAnalyzerId": {
"type": "string",
"description": "Default text analyzer ID for columns without an explicit override."
},
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name"]
}SearchIndex
The SearchIndex schema has only two explicit properties (everything else is inherited from Entity):
{
"description": "An OpenSearch index definition for a specific entity. Represented as a Synapse Entity.",
"properties": {
"definingSQL": {
"type": "string",
"description": "Required. Must reference exactly one entity."
},
"searchConfigurationId": {
"type": "string",
"description": "Optional reference to a SearchConfiguration."
}
},
"required": ["definingSQL"]
}Note: Index state (
CREATING,ACTIVE,FAILED,DELETING) is stored in the indexing DB'sSEARCH_INDEX_STATUStable and is not a property of theSearchIndexentity itself.
As a Synapse Entity, SearchIndex also includes the standard entity fields inherited from Entity, such as:
idconcreteType(must be"org.sagebionetworks.repo.model.table.search.SearchIndex")parentId(Project or Folder)nameetagcreatedOn,createdBymodifiedOn,modifiedByversionNumberetc.
SearchIndexState
Index state is stored in the indexing DB, not on the entity itself.
Value | Description |
|---|---|
| Index build in progress. Queries submitted against this index will automatically retry until the build completes. |
| Index is live and serving queries. |
| Last build failed. Queries will fail with an error. Delete or update the |
| AOSS index cleanup in progress. |
SearchQuery
{
"description": "A structured query against a SearchIndex's OpenSearch index.",
"properties": {
"searchIndexId": {
"type": "string",
"description": "The ID of the SearchIndex entity to query. Supplied by the client in the request body."
},
"queryType": {
"type": "string",
"enum": [
"SIMPLE_QUERY_STRING",
"MATCH",
"MULTI_MATCH",
"MATCH_PHRASE",
"PREFIX",
"WILDCARD",
"MATCH_ALL"
],
"description": "Full-text query type. Default: SIMPLE_QUERY_STRING."
},
"queryText": {
"type": "string",
"description": "Search text. Null/empty = match all."
},
"queryFields": {
"type": "array",
"items": { "type": "string" },
"description": "Column names with optional boost (e.g., 'studyName^3'). Empty = all indexed fields."
},
"booleanFilters": {
"type": "array",
"items": { "$ref": "#KeyValue" },
"description": "Exact-match filters."
},
"termsFilters": {
"type": "array",
"items": { "$ref": "#KeyValues" },
"description": "Multi-value filters (IN clause)."
},
"rangeFilters": {
"type": "array",
"items": { "$ref": "#KeyRange" },
"description": "Range filters with min/max."
},
"existsFilters": {
"type": "array",
"items": { "type": "string" },
"description": "Columns that must have a non-null value."
},
"notExistsFilters": {
"type": "array",
"items": { "type": "string" },
"description": "Columns that must be null/missing."
},
"fuzziness": {
"type": "string",
"description": "Typo tolerance: 'AUTO', '0', '1', '2'."
},
"facetRequests": {
"type": "array",
"items": { "$ref": "#FacetRequest" },
"description": "Columns to aggregate as facets."
},
"returnFields": {
"type": "array",
"items": { "type": "string" },
"description": "Columns to include in results. Empty = all."
},
"sort": {
"type": "array",
"items": { "$ref": "#SortField" },
"description": "Sort order. Default: relevance descending."
},
"highlight": {
"type": "boolean",
"description": "Return highlighted snippets. Default: false."
},
"from": {
"type": "integer",
"description": "Zero-based pagination offset. Default: 0."
},
"size": {
"type": "integer",
"description": "Results per page. Default: 25. Max: 100."
}
},
"required": ["searchIndexId"]
}SearchResults
{
"description": "Results of a search query against a SearchIndex's OpenSearch index.",
"properties": {
"searchIndexId": { "type": "string" },
"totalHits": { "type": "integer" },
"hits": { "type": "array", "items": { "$ref": "#SearchHit" } },
"facets": { "type": "array", "items": { "$ref": "#FacetColumnResult" } },
"from": { "type": "integer" }
},
"required": ["searchIndexId", "totalHits", "hits"]
}SearchHit
{
"description": "A single search result hit.",
"properties": {
"rowId": { "type": "integer" },
"rowVersion": { "type": "integer" },
"score": { "type": "number" },
"fields": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"highlights": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["rowId", "rowVersion", "score", "fields"]
}
Reminder for myself based on meetings from the Weekly meeting:
In this first iteration the idea is to drop the requirement for blue-green reindexing and rebuilds of the data and instead rely on the weekly stack migration to occur in order to rebuild that index. This would allow us to significantly cut down on costs.
The flow would generally look like:
Worker 1 continues to publish messages as designed in order to indicate when an index should be built/rebuilt
Worker 2 now changes to only build a NEW index in opensearch IF there is no index present
This allows us to force a rebuild of an index (Such as to remove data that is no longer public) by:
Deleting the current index in opensearch - THIS WILL CAUSE AN OUTAGE OF THE INDEX AT THIS POINT
Perform some sort of update to the table or index in question that would cause a table change event to be fired off
Worker 1 sees that and publishes a rebuild message, and worker 2 picks that message up - Sees that there is NO index, and builds it
The multi-layer config is a useful feature that we should look to include which would allow us to make a single change (Such as updating a synonymSet) in one place and have it applied to 0..* search indexes.