Skip to content

Commit

Permalink
order + orderOpenApiSpecification
Browse files Browse the repository at this point in the history
  • Loading branch information
dphuang2 committed Oct 9, 2023
1 parent 3b3ca2e commit b94310b
Show file tree
Hide file tree
Showing 8 changed files with 342 additions and 5 deletions.
2 changes: 1 addition & 1 deletion customers/decentro/decentro-in-collections-sdk
2 changes: 1 addition & 1 deletion customers/decentro/decentro-in-kyc-sdk
2 changes: 1 addition & 1 deletion customers/eyelevel/groundx-sdks
2 changes: 1 addition & 1 deletion customers/newscatcher/newscatcher-sdks
Original file line number Diff line number Diff line change
Expand Up @@ -170,16 +170,63 @@ export const readmeHeader = z
.optional()
.describe('Call to action to be displayed in SDKs README.md')

// Define an operation, which can be either an operationId or a combination of subpath and HTTP method
const Operation = z.union([
z
.string()
.describe(
'This represents the operationId from the OpenAPI specification.'
),
z
.object({
path: z.string().describe("This represents the operation's subpath."),
method: z
.string()
.describe("This represents the operation's HTTP method."),
})
.describe("This represents the operation's subpath and HTTP method."),
])

// Define a schema for a tag along with its associated operations
const TagWithOperations = z
.object({
tag: z
.string()
.describe('This represents the tag name from the OpenAPI specification.'),
operations: z
.array(Operation)
.optional()
.describe(
'An ordered list of operations associated with the tag. Each operation can be an operationId or a combination of subpath and HTTP method.'
),
})
.describe(
'A schema that represents a tag and its associated operations in a user-defined order.'
)

// Define the main schema for the API order configuration
const ApiOrderConfigurationSchema = z
.array(TagWithOperations)
.describe(
'An ordered list of tags, where each tag has its associated operations. This represents the user-defined order for displaying tags and operations in the API documentation.'
)

export type ApiOrderConfiguration = z.infer<typeof ApiOrderConfigurationSchema>

export const KonfigYamlCommon = z
.object({
primaryColor,
portal,
order: ApiOrderConfigurationSchema.optional(),
readmeHeader,
readmeOperation: z
.object({
operationId: z.string(),
})
.optional(),
.optional()
.describe(
`Operation to be displayed in "Getting Started" section of SDKs README.md`
),
portalTitle: z
.string()
.optional()
Expand Down
2 changes: 2 additions & 0 deletions generator/konfig-dash/packages/konfig-lib/src/parseSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { convertSwaggerToOAS3 } from './convertSwaggerToOAS3'

export type SchemaObject = OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject

export type PathsObject = OpenAPIV3.PathsObject | OpenAPIV3_1.PathsObject

export type RequestBodyObject =
| OpenAPIV3.RequestBodyObject
| OpenAPIV3_1.RequestBodyObject
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Spec } from '../parseSpec'
import { orderOpenApiSpecification } from './order-openapi-specification'

describe('orderOpenApiSpecification', () => {
it('should order tags and operations based on user configuration', () => {
const spec: Spec['spec'] = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/test4': {
get: {
tags: ['test4'],
operationId: 'test4',
responses: {},
},
},
'/test3': {
get: {
tags: ['test3'],
operationId: 'test3',
responses: {},
},
},
'/test': {
get: {
tags: ['test'],
operationId: 'test',
responses: {},
},
},
'/test-x': {
get: {
tags: ['test'],
operationId: 'test-x',
responses: {},
},
},
'/test2': {
get: {
tags: ['test2'],
operationId: 'test2',
responses: {},
},
},
},
tags: [
{
name: 'test2',
},
{
name: 'test',
},
{
name: 'test4',
},
{
name: 'test3',
},
],
}

const orderedSpec: Spec['spec'] = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/test-x': {
get: {
tags: ['test'],
operationId: 'test-x',
responses: {},
},
},
'/test': {
get: {
tags: ['test'],
operationId: 'test',
responses: {},
},
},
'/test2': {
get: {
tags: ['test2'],
operationId: 'test2',
responses: {},
},
},
'/test4': {
get: {
tags: ['test4'],
operationId: 'test4',
responses: {},
},
},
'/test3': {
get: {
tags: ['test3'],
operationId: 'test3',
responses: {},
},
},
},
tags: [
{
name: 'test',
},
{
name: 'test2',
},
{
name: 'test4',
},
{
name: 'test3',
},
],
}

orderOpenApiSpecification({
spec,
order: [
{
tag: 'test',
operations: [
'test-x',
{
path: '/test',
method: 'get',
},
],
},
{
tag: 'test2',
},
],
})

expect(spec.tags).toStrictEqual(orderedSpec.tags)
expect(Object.keys(spec.paths)).toStrictEqual(
Object.keys(orderedSpec.paths)
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { ApiOrderConfiguration } from '../KonfigYamlCommon'
import { Operation, OperationObject } from '../forEachOperation'
import { getOperations } from '../get-operations'
import { PathsObject, Spec } from '../parseSpec'

/**
* Order's tags / operations in an OpenAPI specification according to the ApiOrderConfiguration object.
*
* Use the following algorithm to order the tags and operations in an OpenAPI specification:
*
* Group Configured vs. Unconfigured:
*
* Display the tags and operations that the user has configured first, in the
* order they've specified. Then, display the unconfigured tags and operations
* in their original order. This approach clearly separates what the user has
* actively configured from what they haven't.
*
*
*/
export function orderOpenApiSpecification({
spec,
order,
}: {
spec: Spec['spec']
order: ApiOrderConfiguration
}): void {
// first order tags by user configuration
const tags = spec.tags
const tagOrder = order.map(({ tag }) => tag)
if (tags !== undefined) {
tags.sort((a, b) => {
const aIndex = tagOrder.indexOf(a.name)
const bIndex = tagOrder.indexOf(b.name)
if (aIndex === -1 && bIndex === -1) {
return 0
} else if (aIndex === -1) {
return 1
} else if (bIndex === -1) {
return -1
} else {
return aIndex - bIndex
}
})
}

// then order operations by user configuration
// do this by sorting every operation based on two things in order of priority:
// 1. tag
// 2. operationId / subpath + method
// this will ensure that operations are grouped by tag and then sorted by operationId / subpath + method
//
// even thought object keys are unordered in theory, in Node.JS, you can
// reconstruct the paths object with ordered operations using insertion order.
// see: https://stackoverflow.com/a/5525820/3068233
//
// first we'll extract all the operations and order them by the above priority.
// then we'll reconstruct the paths object with the ordered operations.
const paths: PathsObject | undefined = spec.paths
if (paths !== undefined) {
const operations: OperationObject[] = getOperations({ spec })

// sort by order of priority: tags then operationId / subpath + method
operations.sort((a, b) => {
const aPath = a.path
const bPath = b.path
const aMethod = a.method
const bMethod = b.method
const aTags = a.operation.tags
const aTag = aTags !== undefined && aTags.length > 0 ? aTags[0] : ''
const aTagIndex = tagOrder.indexOf(aTag)
const bTags = b.operation.tags
const bTag = bTags !== undefined && bTags.length > 0 ? bTags[0] : ''
const bTagIndex = tagOrder.indexOf(bTag)

// first order by tag if possible
if (aTagIndex === -1 && bTagIndex === -1) {
// if neither has a tag, then order by original order
return 0
} else if (aTagIndex === -1) {
// if only a has a tag, then a comes after b
return 1
} else if (bTagIndex === -1) {
// if only b has a tag, then a comes before b
return -1
} else if (aTagIndex !== bTagIndex) {
return aTagIndex - bTagIndex
}

// we are now comparing two operations in the same tag
const operationOrder = order.find(({ tag }) => tag === aTag)?.operations // this will always be defined because we already checked that aTagIndex !== -1

if (operationOrder === undefined) {
// if no operation ordering then order by original order
return 0
}

const aOperationId = a.operation.operationId
const bOperationId = b.operation.operationId

if (aOperationId === bOperationId) {
return 0
}

const aOperationIndex = operationOrder.findIndex((value) =>
typeof value === 'string'
? value === aOperationId
: value.path === aPath && value.method === aMethod
)
const bOperationIndex = operationOrder.findIndex((value) =>
typeof value === 'string'
? value === bOperationId
: value.path === bPath && value.method === bMethod
)

if (aOperationIndex === -1 && bOperationIndex === -1) {
// if neither has an operationId, then order by original order
return 0
} else if (aOperationIndex === -1) {
// if only a has an operationId, then a comes after b
return 1
} else if (bOperationIndex === -1) {
// if only b has an operationId, then a comes before b
return -1
}

return aOperationIndex - bOperationIndex
})

const paths: PathsObject = {}

for (const operation of operations) {
paths[operation.path] = paths[operation.path] ?? {}
const pathItemObject = paths[operation.path]
if (pathItemObject === undefined)
throw Error('pathItemObject should be defined')
pathItemObject[operation.method] = operation.operation as Operation
}
spec.paths = paths
}
}

0 comments on commit b94310b

Please sign in to comment.