@grafana/openapi-to-k6 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/tests.yaml +1 -0
  2. package/README.md +6 -4
  3. package/dist/cli.js +27 -12
  4. package/dist/errors.js +10 -0
  5. package/dist/generator/index.js +50 -3
  6. package/dist/helper.js +32 -0
  7. package/examples/basic_schema/single/simpleAPI.ts +1 -1
  8. package/examples/basic_schema/split/simpleAPI.schemas.ts +1 -1
  9. package/examples/basic_schema/split/simpleAPI.ts +1 -1
  10. package/examples/basic_schema/tags/default.ts +1 -1
  11. package/examples/basic_schema/tags/simpleAPI.schemas.ts +1 -1
  12. package/examples/form_data_schema/single/formDataAPI.ts +1 -1
  13. package/examples/form_data_schema/split/formDataAPI.schemas.ts +1 -1
  14. package/examples/form_data_schema/split/formDataAPI.ts +1 -1
  15. package/examples/form_data_schema/tags/default.ts +1 -1
  16. package/examples/form_data_schema/tags/formDataAPI.schemas.ts +1 -1
  17. package/examples/form_url_encoded_data_schema/single/formURLEncodedAPI.ts +1 -1
  18. package/examples/form_url_encoded_data_schema/split/formURLEncodedAPI.schemas.ts +1 -1
  19. package/examples/form_url_encoded_data_schema/split/formURLEncodedAPI.ts +1 -1
  20. package/examples/form_url_encoded_data_schema/tags/default.ts +1 -1
  21. package/examples/form_url_encoded_data_schema/tags/formURLEncodedAPI.schemas.ts +1 -1
  22. package/examples/form_url_encoded_data_with_query_params_schema/single/formURLEncodedAPIWithQueryParameters.ts +1 -1
  23. package/examples/form_url_encoded_data_with_query_params_schema/split/formURLEncodedAPIWithQueryParameters.schemas.ts +1 -1
  24. package/examples/form_url_encoded_data_with_query_params_schema/split/formURLEncodedAPIWithQueryParameters.ts +1 -1
  25. package/examples/form_url_encoded_data_with_query_params_schema/tags/default.ts +1 -1
  26. package/examples/form_url_encoded_data_with_query_params_schema/tags/formURLEncodedAPIWithQueryParameters.schemas.ts +1 -1
  27. package/examples/get_request_with_path_parameters_schema/single/simpleAPI.ts +1 -1
  28. package/examples/get_request_with_path_parameters_schema/split/simpleAPI.schemas.ts +1 -1
  29. package/examples/get_request_with_path_parameters_schema/split/simpleAPI.ts +1 -1
  30. package/examples/get_request_with_path_parameters_schema/tags/default.ts +1 -1
  31. package/examples/get_request_with_path_parameters_schema/tags/simpleAPI.schemas.ts +1 -1
  32. package/examples/headers_schema/single/headerDemoAPI.ts +1 -1
  33. package/examples/headers_schema/split/headerDemoAPI.schemas.ts +1 -1
  34. package/examples/headers_schema/split/headerDemoAPI.ts +1 -1
  35. package/examples/headers_schema/tags/default.ts +1 -1
  36. package/examples/headers_schema/tags/headerDemoAPI.schemas.ts +1 -1
  37. package/examples/no_title_schema/single/k6Client.ts +1 -1
  38. package/examples/no_title_schema/split/k6Client.schemas.ts +1 -1
  39. package/examples/no_title_schema/split/k6Client.ts +1 -1
  40. package/examples/no_title_schema/tags/default.ts +1 -1
  41. package/examples/no_title_schema/tags/k6Client.schemas.ts +1 -1
  42. package/examples/post_request_with_query_params/single/exampleAPI.ts +1 -1
  43. package/examples/post_request_with_query_params/split/exampleAPI.schemas.ts +1 -1
  44. package/examples/post_request_with_query_params/split/exampleAPI.ts +1 -1
  45. package/examples/post_request_with_query_params/tags/default.ts +1 -1
  46. package/examples/post_request_with_query_params/tags/exampleAPI.schemas.ts +1 -1
  47. package/examples/query_params_schema/single/exampleAPI.ts +1 -1
  48. package/examples/query_params_schema/split/exampleAPI.schemas.ts +1 -1
  49. package/examples/query_params_schema/split/exampleAPI.ts +1 -1
  50. package/examples/query_params_schema/tags/default.ts +1 -1
  51. package/examples/query_params_schema/tags/exampleAPI.schemas.ts +1 -1
  52. package/examples/simple_post_request_schema/single/exampleAPI.ts +1 -1
  53. package/examples/simple_post_request_schema/split/exampleAPI.schemas.ts +1 -1
  54. package/examples/simple_post_request_schema/split/exampleAPI.ts +1 -1
  55. package/examples/simple_post_request_schema/tags/default.ts +1 -1
  56. package/examples/simple_post_request_schema/tags/exampleAPI.schemas.ts +1 -1
  57. package/package.json +1 -1
  58. package/src/cli.ts +32 -14
  59. package/src/errors.ts +6 -0
  60. package/src/generator/index.ts +67 -2
  61. package/src/helper.ts +40 -0
  62. package/src/type.d.ts +1 -0
  63. package/tests/e2e/schema.json +1 -0
  64. package/tests/e2e/single-tag-filter/k6Script.ts +50 -0
  65. package/tests/e2e/tags/k6Script.ts +3 -3
  66. package/tests/functional-tests/fixtures/tags_filtering.json +141 -0
  67. package/tests/functional-tests/generator.test.ts +159 -3
  68. package/tests/helper.test.ts +59 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Automatically generated by @grafana/openapi-to-k6: 0.2.5
2
+ * Automatically generated by @grafana/openapi-to-k6: 0.2.6
3
3
  * Do not edit manually.
4
4
  * Example API
5
5
  * API with all formats of data in the POST request body
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Automatically generated by @grafana/openapi-to-k6: 0.2.5
2
+ * Automatically generated by @grafana/openapi-to-k6: 0.2.6
3
3
  * Do not edit manually.
4
4
  * Example API
5
5
  * API with all formats of data in the POST request body
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Automatically generated by @grafana/openapi-to-k6: 0.2.5
2
+ * Automatically generated by @grafana/openapi-to-k6: 0.2.6
3
3
  * Do not edit manually.
4
4
  * Example API
5
5
  * API with all formats of data in the POST request body
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Automatically generated by @grafana/openapi-to-k6: 0.2.5
2
+ * Automatically generated by @grafana/openapi-to-k6: 0.2.6
3
3
  * Do not edit manually.
4
4
  * Example API
5
5
  * API with all formats of data in the POST request body
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/openapi-to-k6",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "A CLI tool to generate helper modules for K6 from OpenAPI schema",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import chalk from 'chalk'
4
4
  import { Command, InvalidArgumentError } from 'commander'
5
5
  import { generateDefaultAnalyticsData, reportUsageAnalytics } from './analytics'
6
6
  import { Mode } from './constants'
7
+ import { NoFilesGeneratedError } from './errors'
7
8
  import generateK6SDK from './generator'
8
9
  import { getPackageDetails } from './helper'
9
10
  import { logger } from './logger'
@@ -33,10 +34,21 @@ async function generateSDK({
33
34
  shouldGenerateSampleK6Script,
34
35
  analyticsData,
35
36
  mode,
37
+ tags,
36
38
  }: GenerateK6SDKOptions) {
37
- logger.logMessage('Generating TypeScript client for k6...\n')
38
- logger.logMessage(`OpenAPI schema: ${openApiPath}`)
39
- logger.logMessage(`Output: ${outputDir}\n`)
39
+ logger.logMessage(
40
+ 'Generating TypeScript client for k6...\n' +
41
+ 'OpenAPI schema: ' +
42
+ chalk.cyan(openApiPath) +
43
+ '\n' +
44
+ 'Output: ' +
45
+ chalk.cyan(outputDir) +
46
+ '\n' +
47
+ (tags?.length
48
+ ? 'Filtering by tag(s): ' + chalk.cyan(tags.join(', '))
49
+ : '') +
50
+ '\n'
51
+ )
40
52
 
41
53
  await generateK6SDK({
42
54
  openApiPath,
@@ -44,16 +56,13 @@ async function generateSDK({
44
56
  shouldGenerateSampleK6Script,
45
57
  analyticsData,
46
58
  mode,
59
+ tags,
47
60
  })
48
61
 
49
- if (shouldGenerateSampleK6Script) {
50
- logger.logMessage(
51
- `TypeScript client and sample k6 script generated successfully.`,
52
- chalk.green
53
- )
54
- } else {
55
- logger.logMessage(`TypeScript client generated successfully.`, chalk.green)
56
- }
62
+ const message = shouldGenerateSampleK6Script
63
+ ? 'TypeScript client and sample k6 script generated successfully.'
64
+ : 'TypeScript client generated successfully.'
65
+ logger.logMessage(message, chalk.green)
57
66
  }
58
67
 
59
68
  program
@@ -68,6 +77,10 @@ program
68
77
  validateMode,
69
78
  Mode.SINGLE
70
79
  )
80
+ .option(
81
+ '--only-tags <filters...>',
82
+ 'list of tags to filter on. Generated client will only include operations with these tags'
83
+ )
71
84
  .option('-v, --verbose', 'enable verbose mode to show debug logs')
72
85
  .option('--include-sample-script', 'generate a sample k6 script')
73
86
  .option('--disable-analytics', 'disable anonymous usage data collection')
@@ -78,6 +91,7 @@ program
78
91
  options: {
79
92
  verbose?: boolean
80
93
  mode: Mode
94
+ onlyTags?: (string | RegExp)[]
81
95
  disableAnalytics?: boolean
82
96
  includeSampleScript?: boolean
83
97
  }
@@ -112,16 +126,20 @@ program
112
126
  shouldGenerateSampleK6Script: !!options.includeSampleScript,
113
127
  analyticsData,
114
128
  mode: options.mode,
129
+ tags: options.onlyTags,
115
130
  })
116
131
  } catch (error) {
117
- logger.error('Failed to generate SDK:')
118
- console.error(error)
132
+ if (error instanceof NoFilesGeneratedError) {
133
+ logger.logMessage(error.message, chalk.yellow)
134
+ } else {
135
+ logger.error('Failed to generate SDK:')
136
+ console.error(error)
137
+ }
119
138
  }
120
139
 
121
140
  if (!shouldDisableAnalytics && analyticsData) {
122
141
  logger.debug('Reporting following usage analytics data:')
123
142
  logger.debug(JSON.stringify(analyticsData, null, 2))
124
-
125
143
  await reportUsageAnalytics(analyticsData)
126
144
  }
127
145
  }
package/src/errors.ts ADDED
@@ -0,0 +1,6 @@
1
+ export class NoFilesGeneratedError extends Error {
2
+ constructor(message: string = 'No files were generated') {
3
+ super(message)
4
+ this.name = 'NoFilesGeneratedError'
5
+ }
6
+ }
@@ -3,9 +3,11 @@ import { InfoObject } from 'openapi3-ts/oas30'
3
3
  import orval from 'orval'
4
4
  import path from 'path'
5
5
  import { DEFAULT_SCHEMA_TITLE } from '../constants'
6
+ import { NoFilesGeneratedError } from '../errors'
6
7
  import {
7
8
  formatFileWithPrettier,
8
9
  getPackageDetails,
10
+ hasOnlyComments,
9
11
  OutputOverrider,
10
12
  } from '../helper'
11
13
  import { logger } from '../logger'
@@ -26,7 +28,32 @@ const generatedFileHeaderGenerator = (info: InfoObject) => {
26
28
  }
27
29
 
28
30
  const afterAllFilesWriteHandler = async (filePaths: string[]) => {
31
+ const removeSingleFile = (filePath: string) => {
32
+ try {
33
+ fs.unlinkSync(filePath)
34
+ } catch (error) {
35
+ // This is non-critical, so we just log it
36
+ logger.debug(
37
+ `afterAllFilesWriteHandler ~ Error deleting file ${filePath}: ${error}`
38
+ )
39
+ }
40
+ }
41
+ const emptyFileList: string[] = []
42
+
29
43
  for (const filePath of filePaths) {
44
+ // There is a bug in the orval library which generates empty files when tags filter is applied
45
+ // and no matching endpoints are found and used mode is split or single.
46
+ // It generates the file with only the header comment.
47
+ // Hence, we manually remove those empty files.
48
+ // Issue link - https://github.com/orval-labs/orval/issues/1691
49
+
50
+ if (hasOnlyComments(fs.readFileSync(filePath, 'utf-8'))) {
51
+ emptyFileList.push(filePath)
52
+ // Delete the file
53
+ removeSingleFile(filePath)
54
+ continue
55
+ }
56
+
30
57
  await formatFileWithPrettier(filePath)
31
58
 
32
59
  const fileName = path.basename(filePath)
@@ -42,6 +69,28 @@ const afterAllFilesWriteHandler = async (filePaths: string[]) => {
42
69
  fs.renameSync(filePath, newPath)
43
70
  }
44
71
  }
72
+
73
+ if (emptyFileList.length > 0) {
74
+ logger.debug(
75
+ `afterAllFilesWriteHandler ~ The following files were empty and removed: ${emptyFileList.join(
76
+ ', '
77
+ )}`
78
+ )
79
+ }
80
+
81
+ // Return the list of generated file paths excluding the empty files
82
+ const filteredFilePaths = filePaths.filter(
83
+ (filePath) => !emptyFileList.includes(filePath)
84
+ )
85
+
86
+ // Check if all the filtered file path end with `.schemas.ts`
87
+ if (filteredFilePaths.every((filePath) => filePath.endsWith('.schemas.ts'))) {
88
+ // If yes we should remove them as only schemas files is not needed
89
+ filteredFilePaths.map(removeSingleFile)
90
+ return []
91
+ }
92
+
93
+ return filteredFilePaths
45
94
  }
46
95
 
47
96
  export default async ({
@@ -50,15 +99,20 @@ export default async ({
50
99
  shouldGenerateSampleK6Script,
51
100
  analyticsData,
52
101
  mode,
102
+ tags,
53
103
  }: GenerateK6SDKOptions) => {
54
104
  /**
55
105
  * Note!
56
106
  * 1. override.requestOptions is not supported for the custom K6 client
57
107
  * 2. override.mutator is not supported for the custom K6 client
58
108
  */
109
+ const generatedFilePaths: string[] = []
59
110
  await outputOverrider.redirectOutputToNullStream(async () => {
60
111
  await orval({
61
- input: openApiPath,
112
+ input: {
113
+ target: openApiPath,
114
+ filters: { tags: tags && tags.length > 0 ? tags : undefined },
115
+ },
62
116
  output: {
63
117
  target: outputDir,
64
118
  mode: mode,
@@ -70,8 +124,19 @@ export default async ({
70
124
  headers: true,
71
125
  },
72
126
  hooks: {
73
- afterAllFilesWrite: afterAllFilesWriteHandler,
127
+ afterAllFilesWrite: async (filePaths: string[]) => {
128
+ const filteredFilePaths = await afterAllFilesWriteHandler(filePaths)
129
+ generatedFilePaths.push(...filteredFilePaths)
130
+ },
74
131
  },
75
132
  })
76
133
  })
134
+
135
+ if (generatedFilePaths.length === 0) {
136
+ const tagsMessage =
137
+ tags?.length && tags.length > 0
138
+ ? ` Applied tag filter(s): ${tags.join(', ')}`
139
+ : ''
140
+ throw new NoFilesGeneratedError(`No files were generated.${tagsMessage}`)
141
+ }
77
142
  }
package/src/helper.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { camel, getFileInfo } from '@orval/core'
2
2
  import fs from 'fs'
3
+ import { createSourceFile, ScriptTarget } from 'typescript'
4
+
3
5
  import path from 'path'
4
6
  import { format, resolveConfig } from 'prettier'
5
7
  import packageJson from '../package.json'
@@ -158,3 +160,41 @@ export function getDirectoryForPath(pathString: string): string {
158
160
  // If the path has an extension, it is a file
159
161
  return path.dirname(pathString)
160
162
  }
163
+
164
+ /**
165
+ * Checks if a file contains only comments and whitespace, with no actual code.
166
+ *
167
+ * This function uses TypeScript's parser to accurately detect and remove all types
168
+ * of comments (single-line, multi-line, JSDoc) and whitespace from the input text.
169
+ * If nothing remains after removing comments and whitespace, then the file is
170
+ * considered to contain only comments.
171
+ *
172
+ * @param fileContent - The string content of the file to check
173
+ * @returns True if the file contains only comments and whitespace, false if it contains any actual code
174
+ *
175
+ * @example
176
+ * // Returns true
177
+ * hasOnlyComments('// Just a comment');
178
+ *
179
+ * // Returns false
180
+ * hasOnlyComments('// A comment\nconst x = 1;');
181
+ */
182
+ export function hasOnlyComments(fileContent: string): boolean {
183
+ // Create a source file
184
+ const sourceFile = createSourceFile(
185
+ 'temp.ts',
186
+ fileContent,
187
+ ScriptTarget.Latest,
188
+ true
189
+ )
190
+
191
+ // Remove all comments and whitespace
192
+ const textWithoutComments = sourceFile
193
+ .getFullText()
194
+ .replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') // Remove comments
195
+ .replace(/\s+/g, '') // Remove whitespace
196
+
197
+ // If there's any content left after removing comments and whitespace,
198
+ // then there's actual code
199
+ return textWithoutComments.length === 0
200
+ }
package/src/type.d.ts CHANGED
@@ -31,4 +31,5 @@ export interface GenerateK6SDKOptions {
31
31
  shouldGenerateSampleK6Script?: boolean
32
32
  analyticsData?: AnalyticsData
33
33
  mode: Mode
34
+ tags?: (string | RegExp)[]
34
35
  }
@@ -251,6 +251,7 @@
251
251
  },
252
252
  "/items-header": {
253
253
  "get": {
254
+ "tags": ["ItemsHeader"],
254
255
  "summary": "Get an item with custom headers",
255
256
  "parameters": [
256
257
  {
@@ -0,0 +1,50 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import { check } from 'k6'
3
+ import { Counter } from 'k6/metrics'
4
+ import { ComprehensiveAPIClient } from './sdk.ts'
5
+ /* eslint-enable import/no-unresolved */
6
+
7
+ const CounterErrors = new Counter('Errors')
8
+ const CounterSuccess = new Counter('Success')
9
+ const baseUrl = 'http://localhost:3000'
10
+ const client = new ComprehensiveAPIClient({ baseUrl })
11
+
12
+ export const options = {
13
+ thresholds: {
14
+ Errors: ['count>=1'],
15
+ Success: ['count>=1'],
16
+ // the rate of successful checks should be higher than 90%
17
+ },
18
+ }
19
+
20
+ function checkResponseStatus(response, expectedStatus) {
21
+ const result = check(response, {
22
+ [`status is ${expectedStatus}`]: (r) => r.status === expectedStatus,
23
+ })
24
+
25
+ if (!result) {
26
+ console.error(
27
+ `Check failed! Expected status ${expectedStatus} but got ${response.status}. Following is the response:`
28
+ )
29
+ console.log(JSON.stringify(response, null, 2))
30
+ } else {
31
+ CounterSuccess.add(1)
32
+ }
33
+ }
34
+
35
+ export default function () {
36
+ try {
37
+ const getResponseData = client.getItemsId('1')
38
+ checkResponseStatus(getResponseData.response, 200)
39
+ } catch (error) {
40
+ if (error instanceof TypeError) {
41
+ // Add a k6 check to verify if the error is thrown
42
+ CounterErrors.add(1)
43
+ }
44
+ }
45
+
46
+ const getItemsHeaderResponseData = client.getItemsHeader({
47
+ id: 'test',
48
+ })
49
+ checkResponseStatus(getItemsHeaderResponseData.response, 200)
50
+ }
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable import/no-unresolved */
2
2
  import { check } from 'k6'
3
- import { DefaultClient } from './default.ts'
4
3
  import { ItemsFormClient } from './items-form.ts'
4
+ import { ItemsHeaderClient } from './items-header.ts'
5
5
  import { ItemsClient } from './items.ts'
6
6
 
7
7
  /* eslint-enable import/no-unresolved */
@@ -9,7 +9,7 @@ import { ItemsClient } from './items.ts'
9
9
  const baseUrl = 'http://localhost:3000'
10
10
  const itemsClient = new ItemsClient({ baseUrl })
11
11
  const itemFormClient = new ItemsFormClient({ baseUrl })
12
- const defaultClient = new DefaultClient({ baseUrl })
12
+ const itemsHeaderClient = new ItemsHeaderClient({ baseUrl })
13
13
 
14
14
  export const options = {
15
15
  thresholds: {
@@ -98,7 +98,7 @@ export default function () {
98
98
  // Items form client call end
99
99
 
100
100
  // Default client call start
101
- const getItemsHeaderResponseData = defaultClient.getItemsHeader({
101
+ const getItemsHeaderResponseData = itemsHeaderClient.getItemsHeader({
102
102
  id: 'test',
103
103
  })
104
104
  checkResponseStatus(getItemsHeaderResponseData.response, 200)
@@ -0,0 +1,141 @@
1
+ {
2
+ "openapi": "3.0.0",
3
+ "info": {
4
+ "title": "Sample API",
5
+ "version": "1.0.0"
6
+ },
7
+ "paths": {
8
+ "/users": {
9
+ "get": {
10
+ "tags": ["users"],
11
+ "responses": {
12
+ "200": {
13
+ "description": "Success"
14
+ }
15
+ }
16
+ }
17
+ },
18
+ "/user-profiles": {
19
+ "get": {
20
+ "tags": ["userProfiles"],
21
+ "responses": {
22
+ "200": {
23
+ "description": "Success"
24
+ }
25
+ }
26
+ }
27
+ },
28
+ "/pets": {
29
+ "get": {
30
+ "tags": ["pets"],
31
+ "responses": {
32
+ "200": {
33
+ "description": "Success"
34
+ }
35
+ }
36
+ }
37
+ },
38
+ "/auth": {
39
+ "post": {
40
+ "tags": ["auth"],
41
+ "requestBody": {
42
+ "content": {
43
+ "application/json": {
44
+ "schema": {
45
+ "$ref": "#/components/schemas/AuthRequest"
46
+ }
47
+ }
48
+ }
49
+ },
50
+ "responses": {
51
+ "200": {
52
+ "description": "Success"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "components": {
59
+ "schemas": {
60
+ "AuthRequest": {
61
+ "type": "object",
62
+ "properties": {
63
+ "username": {
64
+ "type": "string",
65
+ "example": "user123"
66
+ },
67
+ "password": {
68
+ "type": "string",
69
+ "format": "password",
70
+ "example": "mypassword"
71
+ }
72
+ },
73
+ "required": ["username", "password"]
74
+ },
75
+ "User": {
76
+ "type": "object",
77
+ "properties": {
78
+ "id": {
79
+ "type": "string",
80
+ "example": "abc123"
81
+ },
82
+ "name": {
83
+ "type": "string",
84
+ "example": "John Doe"
85
+ },
86
+ "email": {
87
+ "type": "string",
88
+ "format": "email",
89
+ "example": "john.doe@example.com"
90
+ }
91
+ },
92
+ "required": ["id", "name", "email"]
93
+ },
94
+ "UserProfile": {
95
+ "type": "object",
96
+ "properties": {
97
+ "id": {
98
+ "type": "string",
99
+ "example": "profile123"
100
+ },
101
+ "userId": {
102
+ "type": "string",
103
+ "example": "abc123"
104
+ },
105
+ "bio": {
106
+ "type": "string",
107
+ "example": "Software developer with 10 years of experience"
108
+ },
109
+ "avatarUrl": {
110
+ "type": "string",
111
+ "format": "uri",
112
+ "example": "https://example.com/avatar.jpg"
113
+ }
114
+ },
115
+ "required": ["id", "userId", "bio"]
116
+ },
117
+ "Pet": {
118
+ "type": "object",
119
+ "properties": {
120
+ "id": {
121
+ "type": "string",
122
+ "example": "pet123"
123
+ },
124
+ "name": {
125
+ "type": "string",
126
+ "example": "Buddy"
127
+ },
128
+ "species": {
129
+ "type": "string",
130
+ "example": "Dog"
131
+ },
132
+ "age": {
133
+ "type": "integer",
134
+ "example": 3
135
+ }
136
+ },
137
+ "required": ["id", "name", "species"]
138
+ }
139
+ }
140
+ }
141
+ }