diff --git a/i18n/dataapi.properties b/i18n/dataapi.properties index 25a68e9..ff416c4 100644 --- a/i18n/dataapi.properties +++ b/i18n/dataapi.properties @@ -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. @@ -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 diff --git a/services/DataApiSpecService.cfc b/services/DataApiSpecService.cfc index 589eb8d..b37b1c1 100644 --- a/services/DataApiSpecService.cfc +++ b/services/DataApiSpecService.cfc @@ -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="" ) ); @@ -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" ) @@ -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="" ) ) ); @@ -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 ] diff --git a/tests/unit/DataApiI18nPropertiesTest.cfc b/tests/unit/DataApiI18nPropertiesTest.cfc index 3d6afb0..444bbda 100644 --- a/tests/unit/DataApiI18nPropertiesTest.cfc +++ b/tests/unit/DataApiI18nPropertiesTest.cfc @@ -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" ); @@ -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(){ diff --git a/tests/unit/DataApiSpecServiceTest.cfc b/tests/unit/DataApiSpecServiceTest.cfc index edfd21d..ab360ab 100644 --- a/tests/unit/DataApiSpecServiceTest.cfc +++ b/tests/unit/DataApiSpecServiceTest.cfc @@ -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();