@voxgig/apidef 3.6.0 → 3.8.0
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.
- package/dist/apidef.d.ts +2 -2
- package/dist/apidef.js +2 -1
- package/dist/apidef.js.map +1 -1
- package/dist/guide/guide.js +19 -6
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +39 -12
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/transform/args.js +3 -3
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/field.d.ts +4 -1
- package/dist/transform/field.js +92 -2
- package/dist/transform/field.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utility.d.ts +7 -1
- package/dist/utility.js +147 -8
- package/dist/utility.js.map +1 -1
- package/package.json +4 -4
- package/src/apidef.ts +2 -0
- package/src/guide/guide.ts +20 -6
- package/src/guide/heuristic01.ts +52 -15
- package/src/transform/args.ts +4 -4
- package/src/transform/field.ts +98 -3
- package/src/utility.ts +163 -10
package/src/transform/field.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { each, getx } from 'jostraca'
|
|
|
4
4
|
|
|
5
5
|
import type { TransformResult, Transform } from '../transform'
|
|
6
6
|
|
|
7
|
-
import { validator, canonize } from '../utility'
|
|
7
|
+
import { validator, canonize, inferFieldType, normalizeFieldName } from '../utility'
|
|
8
8
|
|
|
9
9
|
import { KIT } from '../types'
|
|
10
10
|
|
|
@@ -84,9 +84,10 @@ function resolveOpFields(
|
|
|
84
84
|
|
|
85
85
|
for (let fielddef of fielddefs) {
|
|
86
86
|
const fieldname = (fielddef as any).key$ as string
|
|
87
|
+
const name = canonize(normalizeFieldName(fieldname))
|
|
87
88
|
const mfield: ModelField = {
|
|
88
|
-
name
|
|
89
|
-
type: validator(fielddef.type),
|
|
89
|
+
name,
|
|
90
|
+
type: inferFieldType(name, validator(fielddef.type)),
|
|
90
91
|
req: !!fielddef.required,
|
|
91
92
|
op: {},
|
|
92
93
|
}
|
|
@@ -147,16 +148,108 @@ function findFieldDefs(
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
each(fieldSets, (fieldSet: any) => {
|
|
151
|
+
const requiredNames: string[] = Array.isArray(fieldSet?.required)
|
|
152
|
+
? fieldSet.required : []
|
|
150
153
|
each(fieldSet?.properties, (property: any) => {
|
|
154
|
+
if (requiredNames.includes(property.key$)) {
|
|
155
|
+
property.required = true
|
|
156
|
+
}
|
|
151
157
|
fielddefs.push(property)
|
|
152
158
|
})
|
|
153
159
|
})
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
// Fallback: infer fields from example response data when no schema properties found
|
|
163
|
+
if (0 === fielddefs.length && opdef) {
|
|
164
|
+
const exampleFields = inferFieldsFromExamples(opdef)
|
|
165
|
+
for (const ef of exampleFields) {
|
|
166
|
+
fielddefs.push(ef)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
return fielddefs
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
|
|
174
|
+
function inferFieldsFromExamples(opdef: any): SchemaDef[] {
|
|
175
|
+
const example = findExampleObject(opdef)
|
|
176
|
+
if (null == example || 'object' !== typeof example || Array.isArray(example)) {
|
|
177
|
+
return []
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fielddefs: SchemaDef[] = []
|
|
181
|
+
for (const [key, value] of Object.entries(example)) {
|
|
182
|
+
const fielddef: any = {
|
|
183
|
+
key$: key,
|
|
184
|
+
type: inferTypeFromValue(value),
|
|
185
|
+
}
|
|
186
|
+
fielddefs.push(fielddef)
|
|
187
|
+
}
|
|
188
|
+
return fielddefs
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
function findExampleObject(opdef: any): any {
|
|
193
|
+
const responses = opdef.responses
|
|
194
|
+
if (null == responses) return null
|
|
195
|
+
|
|
196
|
+
const resdef = responses[200] ?? responses[201] ?? responses['200'] ?? responses['201']
|
|
197
|
+
if (null == resdef) return null
|
|
198
|
+
|
|
199
|
+
// OpenAPI 3.x: content.application/json.example
|
|
200
|
+
let example = getx(resdef, 'content "application/json" example')
|
|
201
|
+
if (null != example && 'object' === typeof example) return unwrapExample(example)
|
|
202
|
+
|
|
203
|
+
// OpenAPI 3.x: content.application/json.examples (named examples — take first)
|
|
204
|
+
const examples = getx(resdef, 'content "application/json" examples')
|
|
205
|
+
if (null != examples && 'object' === typeof examples) {
|
|
206
|
+
for (const val of Object.values(examples)) {
|
|
207
|
+
const ex = (val as any)?.value
|
|
208
|
+
if (null != ex && 'object' === typeof ex) return unwrapExample(ex)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// OpenAPI 3.x: content.application/json.schema.example
|
|
213
|
+
example = getx(resdef, 'content "application/json" schema example')
|
|
214
|
+
if (null != example && 'object' === typeof example) return unwrapExample(example)
|
|
215
|
+
|
|
216
|
+
// Swagger 2.0: response.example / response.examples.application/json
|
|
217
|
+
example = resdef.example
|
|
218
|
+
if (null != example && 'object' === typeof example) return unwrapExample(example)
|
|
219
|
+
|
|
220
|
+
example = getx(resdef, 'examples "application/json"')
|
|
221
|
+
if (null != example && 'object' === typeof example) return unwrapExample(example)
|
|
222
|
+
|
|
223
|
+
// Swagger 2.0: schema.example
|
|
224
|
+
example = getx(resdef, 'schema example')
|
|
225
|
+
if (null != example && 'object' === typeof example) return unwrapExample(example)
|
|
226
|
+
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
// If the example is a wrapper with a single array property, unwrap to the first item
|
|
232
|
+
function unwrapExample(example: any): any {
|
|
233
|
+
if (Array.isArray(example)) {
|
|
234
|
+
return example.length > 0 ? example[0] : null
|
|
235
|
+
}
|
|
236
|
+
return example
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
function inferTypeFromValue(value: any): string {
|
|
241
|
+
if (null == value) return 'string'
|
|
242
|
+
if ('boolean' === typeof value) return 'boolean'
|
|
243
|
+
if ('number' === typeof value) {
|
|
244
|
+
return Number.isInteger(value) ? 'integer' : 'number'
|
|
245
|
+
}
|
|
246
|
+
if ('string' === typeof value) return 'string'
|
|
247
|
+
if (Array.isArray(value)) return 'array'
|
|
248
|
+
if ('object' === typeof value) return 'object'
|
|
249
|
+
return 'string'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
160
253
|
function mergeField(
|
|
161
254
|
ment: ModelEntity,
|
|
162
255
|
mop: ModelOp,
|
|
@@ -179,4 +272,6 @@ function mergeField(
|
|
|
179
272
|
|
|
180
273
|
export {
|
|
181
274
|
fieldTransform,
|
|
275
|
+
inferFieldsFromExamples,
|
|
276
|
+
inferTypeFromValue,
|
|
182
277
|
}
|
package/src/utility.ts
CHANGED
|
@@ -120,21 +120,27 @@ function depluralize(word: string): string {
|
|
|
120
120
|
'horses': 'horse',
|
|
121
121
|
'house': 'houses',
|
|
122
122
|
'indices': 'index',
|
|
123
|
+
'lens': 'lens',
|
|
123
124
|
'license': 'licenses',
|
|
124
125
|
'matrices': 'matrix',
|
|
125
126
|
'men': 'man',
|
|
126
127
|
'mice': 'mouse',
|
|
128
|
+
'movies': 'movie',
|
|
127
129
|
'notice': 'notices',
|
|
128
130
|
'oases': 'oasis',
|
|
131
|
+
'phrase': 'phrase',
|
|
129
132
|
'releases': 'release',
|
|
130
133
|
'people': 'person',
|
|
131
134
|
'phenomena': 'phenomenon',
|
|
132
135
|
'practice': 'practices',
|
|
133
136
|
'promise': 'promises',
|
|
137
|
+
'series': 'series',
|
|
138
|
+
'species': 'species',
|
|
134
139
|
'teeth': 'tooth',
|
|
135
140
|
'theses': 'thesis',
|
|
136
141
|
'vertices': 'vertex',
|
|
137
142
|
'women': 'woman',
|
|
143
|
+
'yes': 'yes',
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
if (irregulars[word]) {
|
|
@@ -149,14 +155,12 @@ function depluralize(word: string): string {
|
|
|
149
155
|
|
|
150
156
|
// Rules for regular plurals (applied in order)
|
|
151
157
|
|
|
158
|
+
// -ies -> -y (cities -> city), but only if result is > 2 chars
|
|
152
159
|
if (word.endsWith('ies') && word.length > 3) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// -ies -> -y (cities -> city)
|
|
158
|
-
if (word.endsWith('ies') && word.length > 3) {
|
|
159
|
-
return word.slice(0, -3) + 'y'
|
|
160
|
+
const result = word.slice(0, -3) + 'y'
|
|
161
|
+
if (result.length > 2) {
|
|
162
|
+
return result
|
|
163
|
+
}
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
// -ves -> -f or -fe (wolves -> wolf, knives -> knife)
|
|
@@ -186,10 +190,11 @@ function depluralize(word: string): string {
|
|
|
186
190
|
return word.slice(0, -2)
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
// -s -> remove -s (cats -> cat)
|
|
193
|
+
// -s -> remove -s (cats -> cat), but only if result is > 2 chars
|
|
190
194
|
if (word.endsWith('s') &&
|
|
191
195
|
!word.endsWith('ss') &&
|
|
192
|
-
!word.endsWith('us')
|
|
196
|
+
!word.endsWith('us') &&
|
|
197
|
+
word.length > 3
|
|
193
198
|
) {
|
|
194
199
|
return word.slice(0, -1)
|
|
195
200
|
}
|
|
@@ -726,8 +731,150 @@ function validator(torig: undefined | string | string[]): any {
|
|
|
726
731
|
}
|
|
727
732
|
}
|
|
728
733
|
|
|
734
|
+
const FILE_EXT_RE =
|
|
735
|
+
/\.(php|json|txt|png|jpg|jpeg|gif|svg|xml|html|csv|yml|yaml|md)$/i
|
|
736
|
+
|
|
737
|
+
function transliterate(s: string): string {
|
|
738
|
+
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
739
|
+
}
|
|
740
|
+
|
|
729
741
|
function canonize(s: string) {
|
|
730
|
-
|
|
742
|
+
if (null == s || '' === s) return ''
|
|
743
|
+
return depluralize(snakify(transliterate(s).replace(FILE_EXT_RE, '')))
|
|
744
|
+
.replace(/[^a-zA-Z_0-9]/g, '')
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
// Sanitize a raw slug into a clean kebab-case string suitable for
|
|
749
|
+
// conversion to a valid JS identifier (via camelify/snakify/etc).
|
|
750
|
+
function sanitizeSlug(s: string): string {
|
|
751
|
+
if (null == s || '' === s) return 'unknown'
|
|
752
|
+
// Transliterate accented characters to ASCII
|
|
753
|
+
let out = transliterate(s)
|
|
754
|
+
// Replace underscores and dots with hyphens (treat as word separators)
|
|
755
|
+
out = out.replace(/[_.]/g, '-')
|
|
756
|
+
// Strip all non-alphanumeric, non-hyphen chars
|
|
757
|
+
out = out.replace(/[^a-zA-Z0-9-]/g, '')
|
|
758
|
+
// Collapse multiple/leading/trailing hyphens
|
|
759
|
+
out = out.replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
760
|
+
|
|
761
|
+
// Merge standalone number segments with preceding word
|
|
762
|
+
// e.g. ec-2-shop -> ec2-shop, advice-slip-api-2 -> advice-slip-api2
|
|
763
|
+
const raw = out.split('-').filter(p => p.length > 0)
|
|
764
|
+
const parts: string[] = []
|
|
765
|
+
for (const p of raw) {
|
|
766
|
+
if (/^\d+$/.test(p) && parts.length > 0) {
|
|
767
|
+
parts[parts.length - 1] += p
|
|
768
|
+
} else {
|
|
769
|
+
parts.push(p)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
out = parts.join('-')
|
|
773
|
+
|
|
774
|
+
if (!out) return 'unknown'
|
|
775
|
+
return out
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
const BOOLEAN_NAME_RE = /^(is_|has_|can_|should_|allow_|enabled$|disabled$|active$|visible$|deleted$|verified$|public$|private$|locked$|archived$|blocked$)/
|
|
780
|
+
const INTEGER_NAME_RE = /(_count$|_number$|^total_|^count_|^num_|^limit$|^page$|^offset$|^per_page$|^page_size$|^size$|^skip$)/
|
|
781
|
+
const NUMBER_NAME_RE = /^(latitude$|longitude$|lat$|lng$|lon$|price$|amount$|rate$|score$|weight$|height$|width$|depth$|radius$|distance$|duration$|percentage$|percent$)/
|
|
782
|
+
const STRING_NAME_RE = /^(url$|href$|link$|uri$|email$|name$|title$|description$|slug$|path$|label$|username$|password$|token$|key$)/
|
|
783
|
+
const ID_NAME_RE = /(_id$|^id$)/
|
|
784
|
+
|
|
785
|
+
function inferFieldType(name: string, specType: string): string {
|
|
786
|
+
// Only override $ANY, or $STRING for boolean-patterned names
|
|
787
|
+
if ('`$ANY`' === specType) {
|
|
788
|
+
if (BOOLEAN_NAME_RE.test(name)) return '`$BOOLEAN`'
|
|
789
|
+
if (ID_NAME_RE.test(name)) return '`$STRING`'
|
|
790
|
+
if (INTEGER_NAME_RE.test(name)) return '`$INTEGER`'
|
|
791
|
+
if (NUMBER_NAME_RE.test(name)) return '`$NUMBER`'
|
|
792
|
+
if (STRING_NAME_RE.test(name)) return '`$STRING`'
|
|
793
|
+
}
|
|
794
|
+
else if ('`$STRING`' === specType) {
|
|
795
|
+
if (BOOLEAN_NAME_RE.test(name)) return '`$BOOLEAN`'
|
|
796
|
+
}
|
|
797
|
+
return specType
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
function normalizeFieldName(s: string): string {
|
|
802
|
+
if (null == s || '' === s) return ''
|
|
803
|
+
return s
|
|
804
|
+
.replace(/\[\]/g, '')
|
|
805
|
+
.replace(/[\[\].]+/g, '_')
|
|
806
|
+
.replace(/_+/g, '_')
|
|
807
|
+
.replace(/^_|_$/g, '')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
const MIN_ENTITY_NAME_LEN = 3
|
|
812
|
+
const MAX_ENTITY_NAME_LEN = 67
|
|
813
|
+
|
|
814
|
+
function ensureMinEntityName(
|
|
815
|
+
name: string,
|
|
816
|
+
existing: Record<string, any>,
|
|
817
|
+
): string {
|
|
818
|
+
let padded = name.replace(/[^a-zA-Z_0-9]/g, '').replace(/^_+/, '')
|
|
819
|
+
|
|
820
|
+
// Truncate sentence-length names by taking leading segments
|
|
821
|
+
if (padded.length > MAX_ENTITY_NAME_LEN) {
|
|
822
|
+
const parts = padded.split('_')
|
|
823
|
+
let truncated = ''
|
|
824
|
+
for (const part of parts) {
|
|
825
|
+
const next = truncated === '' ? part : truncated + '_' + part
|
|
826
|
+
if (next.length > MAX_ENTITY_NAME_LEN) break
|
|
827
|
+
truncated = next
|
|
828
|
+
}
|
|
829
|
+
padded = truncated || parts[0].substring(0, MAX_ENTITY_NAME_LEN)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (padded.length > 0 && padded[0] >= '0' && padded[0] <= '9') {
|
|
833
|
+
padded = 'n' + padded
|
|
834
|
+
}
|
|
835
|
+
if (padded.length < MIN_ENTITY_NAME_LEN) {
|
|
836
|
+
const padding = 'nt'.substring(0, MIN_ENTITY_NAME_LEN - padded.length)
|
|
837
|
+
padded = padded + padding
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (padded !== name && null != existing[padded]) {
|
|
841
|
+
let i = 2
|
|
842
|
+
while (null != existing[padded + i]) {
|
|
843
|
+
i++
|
|
844
|
+
}
|
|
845
|
+
padded = padded + i
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return padded
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
const CMP_SUFFIXES = ['_rest_controller', '_controller', '_response', '_request']
|
|
853
|
+
const CMP_PREFIXES = ['get_', 'post_', 'put_', 'delete_', 'patch_']
|
|
854
|
+
|
|
855
|
+
function cleanComponentName(name: string): string {
|
|
856
|
+
let cleaned = name
|
|
857
|
+
|
|
858
|
+
for (const suffix of CMP_SUFFIXES) {
|
|
859
|
+
if (cleaned.endsWith(suffix)) {
|
|
860
|
+
const parts = cleaned.split('_')
|
|
861
|
+
const suffixParts = suffix.split('_').filter(s => s !== '').length
|
|
862
|
+
cleaned = canonize(parts.slice(0, parts.length - suffixParts).join('_'))
|
|
863
|
+
break
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
for (const prefix of CMP_PREFIXES) {
|
|
868
|
+
if (cleaned.startsWith(prefix)) {
|
|
869
|
+
const remainder = cleaned.substring(prefix.length)
|
|
870
|
+
if (remainder.length >= 3) {
|
|
871
|
+
cleaned = remainder
|
|
872
|
+
}
|
|
873
|
+
break
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return cleaned
|
|
731
878
|
}
|
|
732
879
|
|
|
733
880
|
|
|
@@ -942,6 +1089,12 @@ export {
|
|
|
942
1089
|
formatJSONIC,
|
|
943
1090
|
validator,
|
|
944
1091
|
canonize,
|
|
1092
|
+
sanitizeSlug,
|
|
1093
|
+
transliterate,
|
|
1094
|
+
cleanComponentName,
|
|
1095
|
+
ensureMinEntityName,
|
|
1096
|
+
inferFieldType,
|
|
1097
|
+
normalizeFieldName,
|
|
945
1098
|
debugpath,
|
|
946
1099
|
findPathsWithPrefix,
|
|
947
1100
|
writeFileSyncWarn,
|