Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions i18n/dataapi.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ api.version=1.0.0

trait.pagination.title=Pagination
trait.pagination.intro=List endpoints (`GET /entity/{entity}/`) are paginated. Use the `paginationMode` query parameter to choose a pagination strategy per request; if omitted, the default for the entity or API namespace is used. The available strategies for this API are described below.
trait.pagination.intro.single=List endpoints (`GET /entity/{entity}/`) are paginated as described below.
trait.pagination.full.description=**Full (page based, with total count)**\n\nPagination is performed with two query parameters, `page` and `pageSize`. If omitted, these will default to `1` and `100` respectively.\n\nWhen returning a successful response, the API will set three pagination related headers:\n\n1. `X-Total-Records`: The total number of records in the recordset\n2. `X-Total-Pages`: The total number of paginated pages in the recordset (based on the `pageSize`)\n3. `Link`: Links to _next_ and _previous_ URLs to use when fetching the previous or next page of results. In the form: `<{nexthref}>; rel="next", <{prevhref}>; rel="prev"`
trait.pagination.offset.description=**Offset (page based, no total count)**\n\nPagination is performed with the `page` and `pageSize` query parameters (defaulting to `1` and `100`). No total record count is calculated, so the `X-Total-Records` and `X-Total-Pages` headers are _not_ returned. A `Link` header is still provided with _next_ and _previous_ URLs where applicable, in the form: `<{nexthref}>; rel="next", <{prevhref}>; rel="prev"`.
trait.pagination.cursor.description=**Cursor (keyset)**\n\nPagination is performed with a `pageSize` query parameter and an opaque `cursor` query parameter. To fetch the first page, omit the `cursor`. Each successful response includes a `Link` header containing the URL for the _next_ page when more records are available, in the form: `<{nexthref}>; rel="next"`. Follow this link to page through the results. Navigation is forward only and no total record count is returned.
Expand Down Expand Up @@ -58,7 +59,9 @@ operation.queue.batch.delete.post.body.description=Array of queue item IDs (UUID
operation.get.description=Used to fetch **{1}** records from the system.
operation.get.params.paginationMode=Pagination strategy to use for this request. One of `{1}`. Default is `{2}` when omitted.
operation.get.params.page=For page based pagination (`full` and `offset` modes); the page number to fetch. Default is 1. Ignored when `paginationMode` is `cursor`.
operation.get.params.page.fixed=The page number to fetch. Default is 1.
operation.get.params.cursor=For cursor (keyset) pagination; the opaque cursor returned in the `Link` header of the previous response. Omit to fetch the first page.
operation.get.params.cursor.fixed=The opaque cursor returned in the `Link` header of the previous response. Omit to fetch the first page.
operation.get.params.pageSize=For pagination; the number of records per page. Default is 100.
operation.get.params.fields=Comma separated list of fields to fetch per record. Default is all fields. Possible values: [`{2}`].
operation.get.200.description=Array of **{1}** records
Expand Down
120 changes: 80 additions & 40 deletions services/DataApiSpecService.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ component {
private string function _buildPaginationTraitDescription() {
var namespace = $getRequestContext().getValue( name="dataApiNamespace", defaultValue="" );
var modes = _getConfigService().getPaginationModesInUse( namespace );
var sections = [ _i18nNamespaced( uri="dataapi:trait.pagination.intro", defaultValue="" ) ];
var introUri = ArrayLen( modes ) == 1 ? "dataapi:trait.pagination.intro.single" : "dataapi:trait.pagination.intro";
var sections = [ _i18nNamespaced( uri=introUri, defaultValue="" ) ];

for( var mode in modes ) {
ArrayAppend( sections, _i18nNamespaced( uri="dataapi:trait.pagination.#mode#.description", defaultValue="" ) );
Expand All @@ -120,6 +121,76 @@ component {
return ArrayToList( sections, Chr( 10 ) & Chr( 10 ) );
}

private array function _getListEndpointPaginationParams( required array allowedPaginationModes, required string defaultPaginationMode ) {
var params = [];
var allowsCursor = ArrayFindNoCase( arguments.allowedPaginationModes, "cursor" ) > 0;
var allowsPageBased = ArrayFindNoCase( arguments.allowedPaginationModes, "full" ) > 0
|| ArrayFindNoCase( arguments.allowedPaginationModes, "offset" ) > 0;

if ( ArrayLen( arguments.allowedPaginationModes ) > 1 ) {
ArrayAppend( params, {
name = "paginationMode"
, in = "query"
, required = false
, description = _i18nNamespaced(
uri = "dataapi:operation.get.params.paginationMode"
, defaultValue = ""
, data = [ arguments.allowedPaginationModes.toList( ", " ), arguments.defaultPaginationMode ]
)
, schema = { type="string", enum=arguments.allowedPaginationModes, default=arguments.defaultPaginationMode }
} );
}

if ( allowsPageBased ) {
ArrayAppend( params, {
name = "page"
, in = "query"
, required = false
, description = _i18nNamespaced(
uri = allowsCursor ? "dataapi:operation.get.params.page" : "dataapi:operation.get.params.page.fixed"
, defaultValue = ""
)
, schema = { type="integer" }
} );
}

ArrayAppend( params, {
name = "pageSize"
, in = "query"
, required = false
, description = _i18nNamespaced( uri="dataapi:operation.get.params.pageSize", defaultValue="" )
, schema = { type="integer" }
} );

if ( allowsCursor ) {
ArrayAppend( params, {
name = "cursor"
, in = "query"
, required = false
, description = _i18nNamespaced(
uri = allowsPageBased ? "dataapi:operation.get.params.cursor" : "dataapi:operation.get.params.cursor.fixed"
, defaultValue = ""
)
, schema = { type="string" }
} );
}

return params;
}

private struct function _getListEndpointPaginationHeaders( required array allowedPaginationModes ) {
var headers = {
"Link" = { "$ref"="##/components/headers/Link" }
};

if ( ArrayFindNoCase( arguments.allowedPaginationModes, "full" ) > 0 ) {
headers[ "X-Total-Records" ] = { "$ref"="##/components/headers/XTotalRecords" };
headers[ "X-Total-Pages" ] = { "$ref"="##/components/headers/XTotalPages" };
}

return headers;
}

private void function _addCommonHeaderSpecs( required struct spec ) {
spec.components.headers.XTotalRecords = {
description = _i18nNamespaced( "dataapi:headers.XTotalRecords.description" )
Expand Down Expand Up @@ -303,42 +374,15 @@ component {
if ( configService.entityVerbIsSupported( entityName, "get" ) ) {
var fieldsFilterList = configService.getSelectFields( entityName, true ).toList( ", " );
var allowedPaginationModes = configService.getEntityAllowedPaginationModes( entityName );
var defaultPaginationMode = configService.getEntityDefaultPaginationMode( entityName );
var params = [{
name = "paginationMode"
var params = _getListEndpointPaginationParams( allowedPaginationModes, configService.getEntityDefaultPaginationMode( entityName ) );

params.append( {
name = "fields"
, in = "query"
, required = false
, description = _i18nNamespaced(
uri = "dataapi:operation.get.params.paginationMode"
, defaultValue = ""
, data = [ allowedPaginationModes.toList( ", " ), defaultPaginationMode ]
)
, schema = { type="string", enum=allowedPaginationModes, default=defaultPaginationMode }
}, {
name = "page"
, in = "query"
, required = false
, description = _i18nNamespaced( uri="dataapi:operation.get.params.page", defaultValue="", data=[ entityTag ] )
, schema = { type="integer" }
}, {
name = "pageSize"
, in = "query"
, required = false
, description = _i18nNamespaced( uri="dataapi:operation.get.params.pageSize", defaultValue="", data=[ entityTag ] )
, schema = { type="integer" }
}, {
name = "cursor"
, in = "query"
, required = false
, description = _i18nNamespaced( uri="dataapi:operation.get.params.cursor", defaultValue="", data=[ entityTag ] )
, schema = { type="string" }
},{
name = "fields"
, in = "query"
, required = false
, description = _i18nNamespaced( uri="dataapi:operation.get.params.fields", defaultValue="", data=[ entityTag, fieldsFilterList ] )
, schema = { type="string" }
} ];
, description = _i18nNamespaced( uri="dataapi:operation.get.params.fields", defaultValue="", data=[ entityTag, fieldsFilterList ] )
, schema = { type="string" }
} );

for( var field in configService.getFilterFields( entityName ) ) {
var fieldFilterDescription = _i18nNamespaced( uri="dataapi:operation.#entityName#.get.params.fields.#field#.description", defaultValue=_i18nNamespaced( uri=basei18n & "field.#field#.help", defaultValue=_i18nNamespaced( uri="dataapi:field.#field#.description", defaultValue="" ) ) );
Expand Down Expand Up @@ -369,11 +413,7 @@ component {
}
}

var getResponseHeaders = {
"X-Total-Records" = { "$ref"="##/components/headers/XTotalRecords" }
, "X-Total-Pages" = { "$ref"="##/components/headers/XTotalPages" }
, "Link" = { "$ref"="##/components/headers/Link" }
};
var getResponseHeaders = _getListEndpointPaginationHeaders( allowedPaginationModes );

spec.paths[ "/entity/#entityName#/" ].get = {
tags = [ entityTag ]
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/DataApiI18nPropertiesTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ component extends="testbox.system.BaseSpec" {
var bundle = fixtures.loadI18nProperties();

expect( bundle[ "trait.pagination.intro" ] ).toInclude( "paginationMode" );
expect( bundle[ "trait.pagination.intro.single" ] ).toInclude( "paginated as described below" );
expect( bundle[ "trait.pagination.intro.single" ] ).notToInclude( "paginationMode" );
expect( bundle[ "trait.pagination.full.description" ] ).toInclude( "X-Total-Records" );
expect( bundle[ "trait.pagination.full.description" ] ).toInclude( "X-Total-Pages" );
expect( bundle[ "trait.pagination.full.description" ] ).toInclude( "pageSize" );
Expand Down Expand Up @@ -44,6 +46,10 @@ component extends="testbox.system.BaseSpec" {

expect( bundle[ "operation.get.params.page" ] ).toInclude( "cursor" );
expect( bundle[ "operation.get.params.page" ] ).toInclude( "Ignored" );
expect( bundle[ "operation.get.params.page.fixed" ] ).toInclude( "page number" );
expect( bundle[ "operation.get.params.page.fixed" ] ).notToInclude( "paginationMode" );
expect( bundle[ "operation.get.params.cursor.fixed" ] ).toInclude( "cursor" );
expect( bundle[ "operation.get.params.cursor.fixed" ] ).notToInclude( "pagination" );
} );

it( "should preserve multiline error handling documentation including the JSON example", function(){
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/DataApiSpecServiceTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,61 @@ component extends="tests.BaseTest" {
expect( paginationTag.description ).toInclude( "pageSize" );
} );

it( "should document fixed pagination without mode selection when only one mode is configured", function(){
var bundle = _fixtures().loadI18nProperties();
var configSvc = _getConfigService(
defaults = { allowedPaginationModes=[ "offset" ], paginationMode="offset" }
);
var apiSvc = _getDataApiService( configSvc, "", bundle );
var svc = _getSpecService( configSvc, apiSvc, "", bundle );
var spec = svc.getSpec();
var paginationTag = {};
var params = spec.paths[ "/entity/contact/" ].get.parameters;
var paramNames = params.map( function( param ){ return param.name; } );
var responseHeaders = spec.paths[ "/entity/contact/" ].get.responses[ "200" ].headers;

for ( var tag in spec.tags ) {
if ( tag.name == bundle[ "trait.pagination.title" ] ) {
paginationTag = tag;
break;
}
}

expect( paginationTag.description ).toInclude( bundle[ "trait.pagination.intro.single" ] );
expect( paginationTag.description ).notToInclude( "paginationMode" );
expect( paginationTag.description ).toInclude( bundle[ "trait.pagination.offset.description" ] );
expect( paginationTag.description ).notToInclude( bundle[ "trait.pagination.full.description" ] );
expect( paginationTag.description ).notToInclude( bundle[ "trait.pagination.cursor.description" ] );

expect( paramNames ).notToInclude( "paginationMode" );
expect( paramNames ).toInclude( "page" );
expect( paramNames ).toInclude( "pageSize" );
expect( paramNames ).notToInclude( "cursor" );

expect( responseHeaders.keyExists( "Link" ) ).toBeTrue();
expect( responseHeaders.keyExists( "X-Total-Records" ) ).toBeFalse();
expect( responseHeaders.keyExists( "X-Total-Pages" ) ).toBeFalse();
} );

it( "should omit paginationMode and page params for cursor-only entities", function(){
var bundle = _fixtures().loadI18nProperties();
var configSvc = _getConfigService();
var apiSvc = _getDataApiService( configSvc, "", bundle );
var svc = _getSpecService( configSvc, apiSvc, "", bundle );
var spec = svc.getSpec();
var params = spec.paths[ "/entity/restricted_contact/" ].get.parameters;
var paramNames = params.map( function( param ){ return param.name; } );
var responseHeaders = spec.paths[ "/entity/restricted_contact/" ].get.responses[ "200" ].headers;

expect( paramNames ).notToInclude( "paginationMode" );
expect( paramNames ).notToInclude( "page" );
expect( paramNames ).toInclude( "cursor" );
expect( paramNames ).toInclude( "pageSize" );

expect( responseHeaders.keyExists( "Link" ) ).toBeTrue();
expect( responseHeaders.keyExists( "X-Total-Records" ) ).toBeFalse();
} );

it( "should expose paginationMode and page/cursor params on paginated GET endpoints", function(){
var bundle = _fixtures().loadI18nProperties();
var configSvc = _getConfigService();
Expand Down
Loading