Skip to content

Commit 3cb64a2

Browse files
authored
Merge pull request #1109 from BitGo/DX-2788-handle-multibyte
fix: handle multibyte characters in descriptions
2 parents a68a9a8 + 4558e6e commit 3cb64a2

2 files changed

Lines changed: 212 additions & 30 deletions

File tree

packages/openapi-generator/src/comments.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
import { parse as parseComment, Block } from 'comment-parser';
22
import { Schema } from './ir';
33

4+
/**
5+
* Compute the difference between byte length and character length for a string.
6+
* This accounts for multibyte UTF-8 characters.
7+
*/
8+
function computeByteLengthDiff(str: string): number {
9+
return Buffer.byteLength(str, 'utf8') - str.length;
10+
}
11+
412
export function leadingComment(
513
src: string,
614
srcSpanStart: number,
715
start: number,
816
end: number,
917
): Block[] {
10-
let commentString = src.slice(start - srcSpanStart, end - srcSpanStart).trim();
18+
// SWC uses byte offsets, but JavaScript strings use character offsets.
19+
// When there are multibyte UTF-8 characters, we need to adjust.
20+
// Calculate the byte-to-char difference for the portion of source before our slice.
21+
const prefixLength = Math.min(start - srcSpanStart, src.length);
22+
const prefix = src.slice(0, prefixLength);
23+
const byteDiff = computeByteLengthDiff(prefix);
24+
25+
// Adjust the slice offsets by the byte difference
26+
const adjustedStart = start - srcSpanStart - byteDiff;
27+
const adjustedEnd =
28+
end -
29+
srcSpanStart -
30+
computeByteLengthDiff(src.slice(0, Math.min(end - srcSpanStart, src.length)));
31+
32+
let commentString = src
33+
.slice(Math.max(0, adjustedStart), Math.max(0, adjustedEnd))
34+
.trim();
1135

1236
if (commentString.includes(' * ') && !/\/\*\*([\s\S]*?)\*\//.test(commentString)) {
1337
// The comment block seems to be JSDoc but was sliced incorrectly
@@ -16,7 +40,10 @@ export function leadingComment(
1640
const endingSubstring = '\n */';
1741

1842
if (commentString.includes(beginningSubstring)) {
19-
commentString = beginningSubstring + commentString.split(beginningSubstring)[1];
43+
// Use lastIndexOf to get the LAST occurrence of '/**\n' to handle cases where
44+
// the slice includes parts of previous properties
45+
const lastIdx = commentString.lastIndexOf(beginningSubstring);
46+
commentString = commentString.substring(lastIdx);
2047
} else {
2148
switch (commentString.split('\n')[0]) {
2249
case '**':
@@ -35,9 +62,12 @@ export function leadingComment(
3562
}
3663

3764
if (commentString.includes(endingSubstring)) {
38-
commentString = commentString.split(endingSubstring)[0] as string;
65+
// Use indexOf to get the FIRST occurrence of '\n */' after isolating the last comment block
66+
const firstIdx = commentString.indexOf(endingSubstring);
67+
commentString = commentString.substring(0, firstIdx + endingSubstring.length);
68+
} else {
69+
commentString = commentString + endingSubstring;
3970
}
40-
commentString = commentString + endingSubstring;
4171
}
4272

4373
const shouldPreserveLineBreaks = commentString.includes('@preserveLineBreaks');

packages/openapi-generator/test/openapi/comments.test.ts

Lines changed: 178 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@ export const route = h.httpRoute({
690690
method: 'GET',
691691
request: h.httpRequest({
692692
query: {
693-
/**
693+
/**
694694
* This is a bar param.
695695
* @example { "foo": "bar" }
696696
*/
@@ -704,8 +704,8 @@ export const route = h.httpRoute({
704704
*/
705705
foo: t.number,
706706
child: {
707-
/**
708-
* child description
707+
/**
708+
* child description
709709
*/
710710
child: t.array(t.union([t.string, t.number])),
711711
}
@@ -837,8 +837,8 @@ export const route = h.httpRoute({
837837
bar: t.array(t.string),
838838
},
839839
body: {
840-
/**
841-
* This is a foo description.
840+
/**
841+
* This is a foo description.
842842
* @example BitGo Inc
843843
*/
844844
foo: Foo,
@@ -973,8 +973,8 @@ export const route = h.httpRoute({
973973
bar: t.array(t.string),
974974
},
975975
body: {
976-
/**
977-
* This is a foo description.
976+
/**
977+
* This is a foo description.
978978
* @minLength 5
979979
* @maxLength 10
980980
* @example SomeInc
@@ -1070,7 +1070,7 @@ const ROUTE_WITH_OVERRIDING_COMMENTS = `
10701070
import * as t from 'io-ts';
10711071
import * as h from '@api-ts/io-ts-http';
10721072
1073-
/**
1073+
/**
10741074
* @example "abc"
10751075
*/
10761076
const TargetSchema = t.string;
@@ -1083,8 +1083,8 @@ const ParentSchema = t.type({
10831083
export const route = h.httpRoute({
10841084
path: '/foo',
10851085
method: 'POST',
1086-
request: h.httpRequest({
1087-
params: {},
1086+
request: h.httpRequest({
1087+
params: {},
10881088
body: ParentSchema
10891089
}),
10901090
response: {
@@ -1161,7 +1161,7 @@ const ROUTE_WITH_NESTED_OVERRIDEN_COMMENTS = `
11611161
import * as t from 'io-ts';
11621162
import * as h from '@api-ts/io-ts-http';
11631163
1164-
/**
1164+
/**
11651165
* @example "abc"
11661166
*/
11671167
const TargetSchema = t.string;
@@ -1179,8 +1179,8 @@ const GrandParentSchema = t.type({
11791179
export const route = h.httpRoute({
11801180
path: '/foo',
11811181
method: 'POST',
1182-
request: h.httpRequest({
1183-
params: {},
1182+
request: h.httpRequest({
1183+
params: {},
11841184
body: GrandParentSchema
11851185
}),
11861186
response: {
@@ -1281,7 +1281,7 @@ const ROUTE_WITH_OVERRIDEN_COMMENTS_IN_UNION = `
12811281
import * as t from 'io-ts';
12821282
import * as h from '@api-ts/io-ts-http';
12831283
1284-
/**
1284+
/**
12851285
* @example "abc"
12861286
*/
12871287
const TargetSchema = t.string;
@@ -1297,7 +1297,7 @@ const ParentSchema = t.type({
12971297
})
12981298
12991299
const SecondaryParentSchema = t.type({
1300-
/**
1300+
/**
13011301
* This description should show with the overriden example
13021302
* @example "overridden example"
13031303
*/
@@ -1316,8 +1316,8 @@ const GrandParentSchema = t.type({
13161316
export const route = h.httpRoute({
13171317
path: '/foo',
13181318
method: 'POST',
1319-
request: h.httpRequest({
1320-
params: {},
1319+
request: h.httpRequest({
1320+
params: {},
13211321
body: GrandParentSchema
13221322
}),
13231323
response: {
@@ -1450,16 +1450,16 @@ import * as h from '@api-ts/io-ts-http';
14501450
*/
14511451
export const StatusWithDescriptions = t.keyof(
14521452
{
1453-
/**
1454-
* @description Transaction is waiting for approval from authorized users
1453+
/**
1454+
* @description Transaction is waiting for approval from authorized users
14551455
*/
14561456
pendingApproval: 1,
1457-
/**
1458-
* @description Transaction was canceled by the user
1457+
/**
1458+
* @description Transaction was canceled by the user
14591459
*/
14601460
canceled: 1,
1461-
/**
1462-
* @description Transaction was rejected by approvers
1461+
/**
1462+
* @description Transaction was rejected by approvers
14631463
*/
14641464
rejected: 1,
14651465
},
@@ -1497,18 +1497,18 @@ export const StatusWithComments = t.keyof(
14971497
*/
14981498
export const MixedCommentStatus = t.keyof(
14991499
{
1500-
/**
1500+
/**
15011501
* This is just an internal comment about pending status
15021502
*/
15031503
pending: 1,
1504-
/**
1504+
/**
15051505
* processing = a case has been picked up by the Trust Committee Email Worker, and is being...processed
15061506
* @description Transaction is currently being processed by the system
15071507
*/
15081508
processing: 1,
15091509
/** approved by the team after review */
15101510
approved: 1,
1511-
/**
1511+
/**
15121512
* @description Transaction was rejected due to validation failures
15131513
*/
15141514
rejected: 1,
@@ -1959,3 +1959,155 @@ testCase(
19591959
},
19601960
},
19611961
);
1962+
1963+
const ROUTE_WITH_MULTIBYTE_CHARS = `
1964+
import * as t from 'io-ts';
1965+
import * as h from '@api-ts/io-ts-http';
1966+
1967+
export const Body = t.type({
1968+
/**
1969+
* The first name (Latin letters, spaces, hyphens, apostrophes, and periods)
1970+
* @pattern ^[A-Za-zÀ-ÿĀ-ſƀ-ɏ\s'\.\-]+$
1971+
*/
1972+
firstName: t.string,
1973+
/**
1974+
* The last name (Latin letters, spaces, hyphens, apostrophes, and periods)
1975+
* @pattern ^[A-Za-zÀ-ÿĀ-ſƀ-ɏ\s'\.\-]+$
1976+
*/
1977+
lastName: t.string,
1978+
/**
1979+
* The middle name (Latin letters, spaces, hyphens, apostrophes, and periods)
1980+
* @pattern ^[A-Za-zÀ-ÿĀ-ſƀ-ɏ\s'\.\-]+$
1981+
*/
1982+
middleName: t.string,
1983+
/**
1984+
* The phone number of the individual
1985+
* @pattern ^[0-9]{10}$
1986+
*/
1987+
phoneNumber: t.string,
1988+
});
1989+
1990+
/**
1991+
* Route to test multibyte chars
1992+
*
1993+
* @operationId api.v1.multibyteChars
1994+
* @tag Test Routes
1995+
*/
1996+
export const route = h.httpRoute({
1997+
path: '/multibyte-chars',
1998+
method: 'POST',
1999+
request: h.httpRequest({
2000+
body: Body,
2001+
}),
2002+
response: {
2003+
200: {
2004+
result: t.string
2005+
}
2006+
},
2007+
});
2008+
`;
2009+
2010+
testCase('route with multibyte chars', ROUTE_WITH_MULTIBYTE_CHARS, {
2011+
openapi: '3.0.3',
2012+
info: {
2013+
title: 'Test',
2014+
version: '1.0.0',
2015+
},
2016+
paths: {
2017+
'/multibyte-chars': {
2018+
post: {
2019+
summary: 'Route to test multibyte chars',
2020+
operationId: 'api.v1.multibyteChars',
2021+
tags: ['Test Routes'],
2022+
parameters: [],
2023+
requestBody: {
2024+
content: {
2025+
'application/json': {
2026+
schema: {
2027+
properties: {
2028+
firstName: {
2029+
type: 'string',
2030+
description:
2031+
'The first name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2032+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2033+
},
2034+
lastName: {
2035+
type: 'string',
2036+
description:
2037+
'The last name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2038+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2039+
},
2040+
middleName: {
2041+
type: 'string',
2042+
description:
2043+
'The middle name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2044+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2045+
},
2046+
phoneNumber: {
2047+
type: 'string',
2048+
description: 'The phone number of the individual',
2049+
pattern: '^[0-9]{10}$',
2050+
},
2051+
},
2052+
required: ['firstName', 'lastName', 'middleName', 'phoneNumber'],
2053+
type: 'object',
2054+
},
2055+
},
2056+
},
2057+
},
2058+
responses: {
2059+
200: {
2060+
description: 'OK',
2061+
content: {
2062+
'application/json': {
2063+
schema: {
2064+
type: 'object',
2065+
properties: {
2066+
result: {
2067+
type: 'string',
2068+
},
2069+
},
2070+
required: ['result'],
2071+
},
2072+
},
2073+
},
2074+
},
2075+
},
2076+
},
2077+
},
2078+
},
2079+
components: {
2080+
schemas: {
2081+
Body: {
2082+
title: 'Body',
2083+
type: 'object',
2084+
properties: {
2085+
firstName: {
2086+
type: 'string',
2087+
description:
2088+
'The first name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2089+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2090+
},
2091+
lastName: {
2092+
type: 'string',
2093+
description:
2094+
'The last name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2095+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2096+
},
2097+
middleName: {
2098+
type: 'string',
2099+
description:
2100+
'The middle name (Latin letters, spaces, hyphens, apostrophes, and periods)',
2101+
pattern: "^[A-Za-zÀ-ÿĀ-ſƀ-ɏs'.-]+$",
2102+
},
2103+
phoneNumber: {
2104+
type: 'string',
2105+
description: 'The phone number of the individual',
2106+
pattern: '^[0-9]{10}$',
2107+
},
2108+
},
2109+
required: ['firstName', 'lastName', 'middleName', 'phoneNumber'],
2110+
},
2111+
},
2112+
},
2113+
});

0 commit comments

Comments
 (0)