@voxgig/apidef 3.5.0 → 3.7.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.
@@ -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: canonize(fieldname),
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
- return word.slice(0, -3) + 'y'
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,119 @@ 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
- return depluralize(snakify(s)).replace(/[^a-zA-Z_0-9]/g, '')
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
+ const BOOLEAN_NAME_RE = /^(is_|has_|can_|should_|allow_|enabled$|disabled$|active$|visible$|deleted$|verified$|public$|private$|locked$|archived$|blocked$)/
749
+ const INTEGER_NAME_RE = /(_count$|_number$|^total_|^count_|^num_|^limit$|^page$|^offset$|^per_page$|^page_size$|^size$|^skip$)/
750
+ const NUMBER_NAME_RE = /^(latitude$|longitude$|lat$|lng$|lon$|price$|amount$|rate$|score$|weight$|height$|width$|depth$|radius$|distance$|duration$|percentage$|percent$)/
751
+ const STRING_NAME_RE = /^(url$|href$|link$|uri$|email$|name$|title$|description$|slug$|path$|label$|username$|password$|token$|key$)/
752
+ const ID_NAME_RE = /(_id$|^id$)/
753
+
754
+ function inferFieldType(name: string, specType: string): string {
755
+ // Only override $ANY, or $STRING for boolean-patterned names
756
+ if ('`$ANY`' === specType) {
757
+ if (BOOLEAN_NAME_RE.test(name)) return '`$BOOLEAN`'
758
+ if (ID_NAME_RE.test(name)) return '`$STRING`'
759
+ if (INTEGER_NAME_RE.test(name)) return '`$INTEGER`'
760
+ if (NUMBER_NAME_RE.test(name)) return '`$NUMBER`'
761
+ if (STRING_NAME_RE.test(name)) return '`$STRING`'
762
+ }
763
+ else if ('`$STRING`' === specType) {
764
+ if (BOOLEAN_NAME_RE.test(name)) return '`$BOOLEAN`'
765
+ }
766
+ return specType
767
+ }
768
+
769
+
770
+ function normalizeFieldName(s: string): string {
771
+ if (null == s || '' === s) return ''
772
+ return s
773
+ .replace(/\[\]/g, '')
774
+ .replace(/[\[\].]+/g, '_')
775
+ .replace(/_+/g, '_')
776
+ .replace(/^_|_$/g, '')
777
+ }
778
+
779
+
780
+ const MIN_ENTITY_NAME_LEN = 3
781
+ const MAX_ENTITY_NAME_LEN = 67
782
+
783
+ function ensureMinEntityName(
784
+ name: string,
785
+ existing: Record<string, any>,
786
+ ): string {
787
+ let padded = name.replace(/[^a-zA-Z_0-9]/g, '').replace(/^_+/, '')
788
+
789
+ // Truncate sentence-length names by taking leading segments
790
+ if (padded.length > MAX_ENTITY_NAME_LEN) {
791
+ const parts = padded.split('_')
792
+ let truncated = ''
793
+ for (const part of parts) {
794
+ const next = truncated === '' ? part : truncated + '_' + part
795
+ if (next.length > MAX_ENTITY_NAME_LEN) break
796
+ truncated = next
797
+ }
798
+ padded = truncated || parts[0].substring(0, MAX_ENTITY_NAME_LEN)
799
+ }
800
+
801
+ if (padded.length > 0 && padded[0] >= '0' && padded[0] <= '9') {
802
+ padded = 'n' + padded
803
+ }
804
+ if (padded.length < MIN_ENTITY_NAME_LEN) {
805
+ const padding = 'nt'.substring(0, MIN_ENTITY_NAME_LEN - padded.length)
806
+ padded = padded + padding
807
+ }
808
+
809
+ if (padded !== name && null != existing[padded]) {
810
+ let i = 2
811
+ while (null != existing[padded + i]) {
812
+ i++
813
+ }
814
+ padded = padded + i
815
+ }
816
+
817
+ return padded
818
+ }
819
+
820
+
821
+ const CMP_SUFFIXES = ['_rest_controller', '_controller', '_response', '_request']
822
+ const CMP_PREFIXES = ['get_', 'post_', 'put_', 'delete_', 'patch_']
823
+
824
+ function cleanComponentName(name: string): string {
825
+ let cleaned = name
826
+
827
+ for (const suffix of CMP_SUFFIXES) {
828
+ if (cleaned.endsWith(suffix)) {
829
+ const parts = cleaned.split('_')
830
+ const suffixParts = suffix.split('_').filter(s => s !== '').length
831
+ cleaned = canonize(parts.slice(0, parts.length - suffixParts).join('_'))
832
+ break
833
+ }
834
+ }
835
+
836
+ for (const prefix of CMP_PREFIXES) {
837
+ if (cleaned.startsWith(prefix)) {
838
+ const remainder = cleaned.substring(prefix.length)
839
+ if (remainder.length >= 3) {
840
+ cleaned = remainder
841
+ }
842
+ break
843
+ }
844
+ }
845
+
846
+ return cleaned
731
847
  }
732
848
 
733
849
 
@@ -942,6 +1058,11 @@ export {
942
1058
  formatJSONIC,
943
1059
  validator,
944
1060
  canonize,
1061
+ transliterate,
1062
+ cleanComponentName,
1063
+ ensureMinEntityName,
1064
+ inferFieldType,
1065
+ normalizeFieldName,
945
1066
  debugpath,
946
1067
  findPathsWithPrefix,
947
1068
  writeFileSyncWarn,