@nubitio/hydra 0.2.1 → 0.3.1

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/index.cjs CHANGED
@@ -359,9 +359,17 @@ function extractEntrypointCollectionOperations(doc) {
359
359
  * Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
360
360
  * Record<className, HydraResourceSchema>.
361
361
  *
362
+ * @param entrypointHrefs - Optional map of entrypoint property name → real
363
+ * collection href, read from the API entrypoint document (`GET /api`).
364
+ * When provided it is the **authoritative** source for resource URLs;
365
+ * the dash-case + pluralize heuristic only fills the gaps. The heuristic
366
+ * cannot know the backend's path segment generator (underscores vs dashes)
367
+ * nor handle irregular plurals (Series, Settings, …), so always prefer
368
+ * passing the real hrefs.
369
+ *
362
370
  * @throws Error if doc is not a valid Hydra ApiDocumentation.
363
371
  */
364
- function parseHydraDoc(doc) {
372
+ function parseHydraDoc(doc, entrypointHrefs) {
365
373
  const result = {};
366
374
  const collectionOperationsMap = extractEntrypointCollectionOperations(doc);
367
375
  const urlMap = {};
@@ -372,7 +380,8 @@ function parseHydraDoc(doc) {
372
380
  const propId = sp.property?.["@id"];
373
381
  if (range && propId) {
374
382
  const className = range.replace("#", "");
375
- urlMap[className] = deriveApiUrl(propId);
383
+ const propName = propId.split("/").pop() ?? "";
384
+ urlMap[className] = entrypointHrefs?.[propName] ?? deriveApiUrl(propId);
376
385
  }
377
386
  }
378
387
  for (const cls of doc["supportedClass"]) {
@@ -391,7 +400,8 @@ function parseHydraDoc(doc) {
391
400
  readable,
392
401
  writeable,
393
402
  "hydra:title": sp["title"] ?? sp["hydra:title"] ?? void 0,
394
- crudHints: sp["x-crud"]
403
+ crudHints: sp["x-crud"],
404
+ enumOptions: Array.isArray(sp["enum"]) ? sp["enum"] : void 0
395
405
  });
396
406
  }
397
407
  result[className] = {
@@ -424,7 +434,8 @@ function parseOpenApiDoc(doc) {
424
434
  propertyType: "rdf:Property",
425
435
  required: required.has(fieldName),
426
436
  readable,
427
- writeable
437
+ writeable,
438
+ enumOptions: Array.isArray(prop.enum) ? prop.enum : void 0
428
439
  });
429
440
  }
430
441
  result[className] = {
@@ -495,6 +506,7 @@ function resolveRangeTag(range, propertyType) {
495
506
  * Only properties that are explicitly set (`!== undefined`) are applied.
496
507
  *
497
508
  * `hidden: true` sets `visible: false` so the column is hidden in the grid.
509
+ * `visibleOnForm: false` excludes the field from the create/edit form only.
498
510
  * `order` maps to the `Field.order` property used by native grid column ordering.
499
511
  */
500
512
  function applyCrudHints(field, hints) {
@@ -502,6 +514,7 @@ function applyCrudHints(field, hints) {
502
514
  if (hints.filterable !== void 0) field.filterable = hints.filterable;
503
515
  if (hints.sortable !== void 0) field.sortable = hints.sortable;
504
516
  if (hints.hidden !== void 0 && hints.hidden) field.visible = false;
517
+ if (hints.visibleOnForm !== void 0) field.visibleOnForm = hints.visibleOnForm;
505
518
  if (hints.order !== void 0) field.order = hints.order;
506
519
  if (hints.width !== void 0) field.width = hints.width;
507
520
  }
@@ -590,7 +603,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
590
603
  const filterable = filterableProperties.size === 0 ? true : filterableProperties.has(name);
591
604
  if (!writeable) {
592
605
  const field = {
593
- ...(0, _nubitio_crud.noneField)().name(name).label(label).required(required).build(),
606
+ ...(fieldSchema.crudHints?.format === "currency" ? (0, _nubitio_crud.currencyField)().readonly(true) : (0, _nubitio_crud.noneField)()).name(name).label(label).required(required).build(),
594
607
  filterable
595
608
  };
596
609
  applyCrudHints(field, fieldSchema.crudHints);
@@ -598,6 +611,28 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
598
611
  continue;
599
612
  }
600
613
  const tag = resolveRangeTag(range, propertyType);
614
+ const enumOptions = fieldSchema.enumOptions;
615
+ if (enumOptions && enumOptions.length > 0 && (tag === "text" || tag === "integer" || tag === "decimal")) {
616
+ const field = {
617
+ ...(0, _nubitio_crud.enumField)(enumOptions.map((value) => ({
618
+ value,
619
+ text: toLabel(String(value)).replace(/\b[a-z]/g, (c) => c.toUpperCase())
620
+ }))).name(name).label(label).required(required).build(),
621
+ filterable
622
+ };
623
+ applyCrudHints(field, fieldSchema.crudHints);
624
+ fields.push(field);
625
+ continue;
626
+ }
627
+ if (fieldSchema.crudHints?.format === "currency" && (tag === "text" || tag === "integer" || tag === "decimal")) {
628
+ const field = {
629
+ ...(0, _nubitio_crud.currencyField)().name(name).label(label).required(required).build(),
630
+ filterable
631
+ };
632
+ applyCrudHints(field, fieldSchema.crudHints);
633
+ fields.push(field);
634
+ continue;
635
+ }
601
636
  if (tag === "boolean") {
602
637
  const field = {
603
638
  ...(0, _nubitio_crud.switchField)().name(name).label(label).required(required).build(),
@@ -673,6 +708,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
673
708
  //#region packages/hydra/useHydraMetadata.ts
674
709
  const JSONLD_URL = "/api/docs.jsonld";
675
710
  const JSON_URL = "/api/docs.json";
711
+ const ENTRYPOINT_URL = "/api";
676
712
  const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
677
713
  /**
678
714
  * Try to fetch and validate a Hydra JSON-LD doc.
@@ -694,6 +730,30 @@ async function fetchOpenApiDoc(httpClient, url) {
694
730
  return json;
695
731
  }
696
732
  /**
733
+ * Fetch the API entrypoint (`GET /api`) and extract its property → collection
734
+ * href map: `{ "salesDocument": "/api/sales-documents", … }`.
735
+ *
736
+ * This is the canonical source for resource URLs — it reflects the backend's
737
+ * actual route generator instead of guessing via dash-case + pluralize.
738
+ * Returns `undefined` on any failure (auth-gated entrypoint, network error,
739
+ * unexpected shape) so the caller can fall back to the heuristic; URL
740
+ * discovery must never take the whole admin down.
741
+ */
742
+ async function fetchEntrypointHrefs(httpClient) {
743
+ try {
744
+ const { data } = await httpClient.get(ENTRYPOINT_URL);
745
+ if (!data || typeof data !== "object") return void 0;
746
+ const hrefs = {};
747
+ for (const [key, value] of Object.entries(data)) {
748
+ if (key.startsWith("@")) continue;
749
+ if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
750
+ }
751
+ return Object.keys(hrefs).length > 0 ? hrefs : void 0;
752
+ } catch {
753
+ return;
754
+ }
755
+ }
756
+ /**
697
757
  * Stable query key used by useHydraMetadata.
698
758
  * Export this so that consumers (e.g. SmartCrudPage retry button) can
699
759
  * invalidate exactly the right query without coupling to an internal string.
@@ -707,9 +767,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
707
767
  */
708
768
  async function fetchApiDoc(httpClient) {
709
769
  try {
770
+ const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
710
771
  return {
711
772
  format: "hydra",
712
- doc: await fetchHydraDoc(httpClient, JSONLD_URL)
773
+ doc,
774
+ entrypointHrefs
713
775
  };
714
776
  } catch {}
715
777
  try {
@@ -813,15 +875,19 @@ function useResourceSchema(apiUrl) {
813
875
  error,
814
876
  supportedOperations: []
815
877
  };
816
- const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
878
+ const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
817
879
  const normalizedInput = normalizeUrl(apiUrl);
818
880
  const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
819
- if (!resourceSchema) return {
820
- fields: [],
821
- isLoading: false,
822
- error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc`),
823
- supportedOperations: []
824
- };
881
+ if (!resourceSchema) {
882
+ const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
883
+ const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
884
+ return {
885
+ fields: [],
886
+ isLoading: false,
887
+ error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc.` + (slugMatch ? ` Did you mean ${slugMatch}? If your backend generates underscore paths, configure API Platform's dash generator (path_segment_name_generator: api_platform.metadata.path_segment_name_generator.dash) or update the frontend resource path to match.` : "") + ` Known resource URLs: ${knownUrls.join(", ") || "(none)"}`),
888
+ supportedOperations: []
889
+ };
890
+ }
825
891
  return {
826
892
  fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
827
893
  isLoading: false,
package/dist/index.d.cts CHANGED
@@ -65,12 +65,16 @@ interface CrudHints {
65
65
  filterable?: boolean;
66
66
  /** Override whether the field can be sorted in the grid. */
67
67
  sortable?: boolean;
68
- /** When true, the column is hidden from the grid by default. */
68
+ /** When true, the column is hidden from the grid by default (the form still shows the field). */
69
69
  hidden?: boolean;
70
+ /** When false, the field is excluded from the create/edit form (the grid still shows the column). */
71
+ visibleOnForm?: boolean;
70
72
  /** Column display order. */
71
73
  order?: number;
72
74
  /** Column width in pixels or as a CSS string. */
73
75
  width?: number;
76
+ /** Render hint for scalar fields. 'currency' maps decimals to a currency control. */
77
+ format?: 'currency';
74
78
  }
75
79
  interface HydraSupportedProperty {
76
80
  '@type': 'SupportedProperty';
@@ -99,6 +103,12 @@ interface HydraSupportedProperty {
99
103
  * `#[ApiProperty(openapiContext: ['x-crud' => [...]])]` on the PHP entity.
100
104
  */
101
105
  'x-crud'?: CrudHints;
106
+ /**
107
+ * Allowed values forwarded by `TranslatedDocumentationNormalizer` from
108
+ * `#[ApiProperty(openapiContext: ['enum' => [...]])]`. When present, the
109
+ * field mapper renders a select control instead of a free-text input.
110
+ */
111
+ enum?: Array<string | number>;
102
112
  }
103
113
  /**
104
114
  * A single entry from `supportedOperation` on a Hydra class.
@@ -170,6 +180,8 @@ interface HydraFieldSchema {
170
180
  * When present, these values override inferred field properties in the mapper.
171
181
  */
172
182
  crudHints?: CrudHints;
183
+ /** Allowed values (renders as a select). From `enum` on the supported property. */
184
+ enumOptions?: Array<string | number>;
173
185
  }
174
186
  /**
175
187
  * A single entry from a Hydra `hydra:search` / `hydra:mapping` block.
@@ -218,9 +230,18 @@ interface OpenApiDoc {
218
230
  schemas: Record<string, OpenApiSchema>;
219
231
  };
220
232
  }
233
+ /**
234
+ * Map of entrypoint property name → real collection href, read from the API
235
+ * entrypoint document (`GET /api` → `{ "salesDocument": "/api/sales-documents", … }`).
236
+ * This is the canonical source for resource URLs: when present it overrides
237
+ * the dash-case + pluralize heuristic, which cannot know the backend's path
238
+ * generator or handle irregular plurals.
239
+ */
240
+ type HydraEntrypointHrefs = Record<string, string>;
221
241
  type ApiDoc = {
222
242
  format: 'hydra';
223
243
  doc: HydraApiDoc;
244
+ entrypointHrefs?: HydraEntrypointHrefs;
224
245
  } | {
225
246
  format: 'openapi';
226
247
  doc: OpenApiDoc;
@@ -231,9 +252,17 @@ type ApiDoc = {
231
252
  * Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
232
253
  * Record<className, HydraResourceSchema>.
233
254
  *
255
+ * @param entrypointHrefs - Optional map of entrypoint property name → real
256
+ * collection href, read from the API entrypoint document (`GET /api`).
257
+ * When provided it is the **authoritative** source for resource URLs;
258
+ * the dash-case + pluralize heuristic only fills the gaps. The heuristic
259
+ * cannot know the backend's path segment generator (underscores vs dashes)
260
+ * nor handle irregular plurals (Series, Settings, …), so always prefer
261
+ * passing the real hrefs.
262
+ *
234
263
  * @throws Error if doc is not a valid Hydra ApiDocumentation.
235
264
  */
236
- declare function parseHydraDoc(doc: HydraApiDoc): Record<string, HydraResourceSchema>;
265
+ declare function parseHydraDoc(doc: HydraApiDoc, entrypointHrefs?: HydraEntrypointHrefs): Record<string, HydraResourceSchema>;
237
266
  /**
238
267
  * Parse a raw `/api/docs.json` OpenAPI 3.1 response into the same
239
268
  * Record<className, HydraResourceSchema> output shape so that
package/dist/index.d.mts CHANGED
@@ -65,12 +65,16 @@ interface CrudHints {
65
65
  filterable?: boolean;
66
66
  /** Override whether the field can be sorted in the grid. */
67
67
  sortable?: boolean;
68
- /** When true, the column is hidden from the grid by default. */
68
+ /** When true, the column is hidden from the grid by default (the form still shows the field). */
69
69
  hidden?: boolean;
70
+ /** When false, the field is excluded from the create/edit form (the grid still shows the column). */
71
+ visibleOnForm?: boolean;
70
72
  /** Column display order. */
71
73
  order?: number;
72
74
  /** Column width in pixels or as a CSS string. */
73
75
  width?: number;
76
+ /** Render hint for scalar fields. 'currency' maps decimals to a currency control. */
77
+ format?: 'currency';
74
78
  }
75
79
  interface HydraSupportedProperty {
76
80
  '@type': 'SupportedProperty';
@@ -99,6 +103,12 @@ interface HydraSupportedProperty {
99
103
  * `#[ApiProperty(openapiContext: ['x-crud' => [...]])]` on the PHP entity.
100
104
  */
101
105
  'x-crud'?: CrudHints;
106
+ /**
107
+ * Allowed values forwarded by `TranslatedDocumentationNormalizer` from
108
+ * `#[ApiProperty(openapiContext: ['enum' => [...]])]`. When present, the
109
+ * field mapper renders a select control instead of a free-text input.
110
+ */
111
+ enum?: Array<string | number>;
102
112
  }
103
113
  /**
104
114
  * A single entry from `supportedOperation` on a Hydra class.
@@ -170,6 +180,8 @@ interface HydraFieldSchema {
170
180
  * When present, these values override inferred field properties in the mapper.
171
181
  */
172
182
  crudHints?: CrudHints;
183
+ /** Allowed values (renders as a select). From `enum` on the supported property. */
184
+ enumOptions?: Array<string | number>;
173
185
  }
174
186
  /**
175
187
  * A single entry from a Hydra `hydra:search` / `hydra:mapping` block.
@@ -218,9 +230,18 @@ interface OpenApiDoc {
218
230
  schemas: Record<string, OpenApiSchema>;
219
231
  };
220
232
  }
233
+ /**
234
+ * Map of entrypoint property name → real collection href, read from the API
235
+ * entrypoint document (`GET /api` → `{ "salesDocument": "/api/sales-documents", … }`).
236
+ * This is the canonical source for resource URLs: when present it overrides
237
+ * the dash-case + pluralize heuristic, which cannot know the backend's path
238
+ * generator or handle irregular plurals.
239
+ */
240
+ type HydraEntrypointHrefs = Record<string, string>;
221
241
  type ApiDoc = {
222
242
  format: 'hydra';
223
243
  doc: HydraApiDoc;
244
+ entrypointHrefs?: HydraEntrypointHrefs;
224
245
  } | {
225
246
  format: 'openapi';
226
247
  doc: OpenApiDoc;
@@ -231,9 +252,17 @@ type ApiDoc = {
231
252
  * Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
232
253
  * Record<className, HydraResourceSchema>.
233
254
  *
255
+ * @param entrypointHrefs - Optional map of entrypoint property name → real
256
+ * collection href, read from the API entrypoint document (`GET /api`).
257
+ * When provided it is the **authoritative** source for resource URLs;
258
+ * the dash-case + pluralize heuristic only fills the gaps. The heuristic
259
+ * cannot know the backend's path segment generator (underscores vs dashes)
260
+ * nor handle irregular plurals (Series, Settings, …), so always prefer
261
+ * passing the real hrefs.
262
+ *
234
263
  * @throws Error if doc is not a valid Hydra ApiDocumentation.
235
264
  */
236
- declare function parseHydraDoc(doc: HydraApiDoc): Record<string, HydraResourceSchema>;
265
+ declare function parseHydraDoc(doc: HydraApiDoc, entrypointHrefs?: HydraEntrypointHrefs): Record<string, HydraResourceSchema>;
237
266
  /**
238
267
  * Parse a raw `/api/docs.json` OpenAPI 3.1 response into the same
239
268
  * Record<className, HydraResourceSchema> output shape so that
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createCoreHttpClient, useCoreConfig, useCoreHttpClient } from "@nubitio/core";
2
2
  import { createContext, useContext, useMemo } from "react";
3
- import { ResourceSchemaProvider, ResourceStoreProvider, datetimeField, entityField, noneField, numberField, switchField, textField } from "@nubitio/crud";
3
+ import { ResourceSchemaProvider, ResourceStoreProvider, currencyField, datetimeField, entityField, enumField, noneField, numberField, switchField, textField } from "@nubitio/crud";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import { useQuery } from "@tanstack/react-query";
6
6
  //#region packages/hydra/HydraRemoteDataSource.ts
@@ -335,9 +335,17 @@ function extractEntrypointCollectionOperations(doc) {
335
335
  * Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
336
336
  * Record<className, HydraResourceSchema>.
337
337
  *
338
+ * @param entrypointHrefs - Optional map of entrypoint property name → real
339
+ * collection href, read from the API entrypoint document (`GET /api`).
340
+ * When provided it is the **authoritative** source for resource URLs;
341
+ * the dash-case + pluralize heuristic only fills the gaps. The heuristic
342
+ * cannot know the backend's path segment generator (underscores vs dashes)
343
+ * nor handle irregular plurals (Series, Settings, …), so always prefer
344
+ * passing the real hrefs.
345
+ *
338
346
  * @throws Error if doc is not a valid Hydra ApiDocumentation.
339
347
  */
340
- function parseHydraDoc(doc) {
348
+ function parseHydraDoc(doc, entrypointHrefs) {
341
349
  const result = {};
342
350
  const collectionOperationsMap = extractEntrypointCollectionOperations(doc);
343
351
  const urlMap = {};
@@ -348,7 +356,8 @@ function parseHydraDoc(doc) {
348
356
  const propId = sp.property?.["@id"];
349
357
  if (range && propId) {
350
358
  const className = range.replace("#", "");
351
- urlMap[className] = deriveApiUrl(propId);
359
+ const propName = propId.split("/").pop() ?? "";
360
+ urlMap[className] = entrypointHrefs?.[propName] ?? deriveApiUrl(propId);
352
361
  }
353
362
  }
354
363
  for (const cls of doc["supportedClass"]) {
@@ -367,7 +376,8 @@ function parseHydraDoc(doc) {
367
376
  readable,
368
377
  writeable,
369
378
  "hydra:title": sp["title"] ?? sp["hydra:title"] ?? void 0,
370
- crudHints: sp["x-crud"]
379
+ crudHints: sp["x-crud"],
380
+ enumOptions: Array.isArray(sp["enum"]) ? sp["enum"] : void 0
371
381
  });
372
382
  }
373
383
  result[className] = {
@@ -400,7 +410,8 @@ function parseOpenApiDoc(doc) {
400
410
  propertyType: "rdf:Property",
401
411
  required: required.has(fieldName),
402
412
  readable,
403
- writeable
413
+ writeable,
414
+ enumOptions: Array.isArray(prop.enum) ? prop.enum : void 0
404
415
  });
405
416
  }
406
417
  result[className] = {
@@ -471,6 +482,7 @@ function resolveRangeTag(range, propertyType) {
471
482
  * Only properties that are explicitly set (`!== undefined`) are applied.
472
483
  *
473
484
  * `hidden: true` sets `visible: false` so the column is hidden in the grid.
485
+ * `visibleOnForm: false` excludes the field from the create/edit form only.
474
486
  * `order` maps to the `Field.order` property used by native grid column ordering.
475
487
  */
476
488
  function applyCrudHints(field, hints) {
@@ -478,6 +490,7 @@ function applyCrudHints(field, hints) {
478
490
  if (hints.filterable !== void 0) field.filterable = hints.filterable;
479
491
  if (hints.sortable !== void 0) field.sortable = hints.sortable;
480
492
  if (hints.hidden !== void 0 && hints.hidden) field.visible = false;
493
+ if (hints.visibleOnForm !== void 0) field.visibleOnForm = hints.visibleOnForm;
481
494
  if (hints.order !== void 0) field.order = hints.order;
482
495
  if (hints.width !== void 0) field.width = hints.width;
483
496
  }
@@ -566,7 +579,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
566
579
  const filterable = filterableProperties.size === 0 ? true : filterableProperties.has(name);
567
580
  if (!writeable) {
568
581
  const field = {
569
- ...noneField().name(name).label(label).required(required).build(),
582
+ ...(fieldSchema.crudHints?.format === "currency" ? currencyField().readonly(true) : noneField()).name(name).label(label).required(required).build(),
570
583
  filterable
571
584
  };
572
585
  applyCrudHints(field, fieldSchema.crudHints);
@@ -574,6 +587,28 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
574
587
  continue;
575
588
  }
576
589
  const tag = resolveRangeTag(range, propertyType);
590
+ const enumOptions = fieldSchema.enumOptions;
591
+ if (enumOptions && enumOptions.length > 0 && (tag === "text" || tag === "integer" || tag === "decimal")) {
592
+ const field = {
593
+ ...enumField(enumOptions.map((value) => ({
594
+ value,
595
+ text: toLabel(String(value)).replace(/\b[a-z]/g, (c) => c.toUpperCase())
596
+ }))).name(name).label(label).required(required).build(),
597
+ filterable
598
+ };
599
+ applyCrudHints(field, fieldSchema.crudHints);
600
+ fields.push(field);
601
+ continue;
602
+ }
603
+ if (fieldSchema.crudHints?.format === "currency" && (tag === "text" || tag === "integer" || tag === "decimal")) {
604
+ const field = {
605
+ ...currencyField().name(name).label(label).required(required).build(),
606
+ filterable
607
+ };
608
+ applyCrudHints(field, fieldSchema.crudHints);
609
+ fields.push(field);
610
+ continue;
611
+ }
577
612
  if (tag === "boolean") {
578
613
  const field = {
579
614
  ...switchField().name(name).label(label).required(required).build(),
@@ -649,6 +684,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
649
684
  //#region packages/hydra/useHydraMetadata.ts
650
685
  const JSONLD_URL = "/api/docs.jsonld";
651
686
  const JSON_URL = "/api/docs.json";
687
+ const ENTRYPOINT_URL = "/api";
652
688
  const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
653
689
  /**
654
690
  * Try to fetch and validate a Hydra JSON-LD doc.
@@ -670,6 +706,30 @@ async function fetchOpenApiDoc(httpClient, url) {
670
706
  return json;
671
707
  }
672
708
  /**
709
+ * Fetch the API entrypoint (`GET /api`) and extract its property → collection
710
+ * href map: `{ "salesDocument": "/api/sales-documents", … }`.
711
+ *
712
+ * This is the canonical source for resource URLs — it reflects the backend's
713
+ * actual route generator instead of guessing via dash-case + pluralize.
714
+ * Returns `undefined` on any failure (auth-gated entrypoint, network error,
715
+ * unexpected shape) so the caller can fall back to the heuristic; URL
716
+ * discovery must never take the whole admin down.
717
+ */
718
+ async function fetchEntrypointHrefs(httpClient) {
719
+ try {
720
+ const { data } = await httpClient.get(ENTRYPOINT_URL);
721
+ if (!data || typeof data !== "object") return void 0;
722
+ const hrefs = {};
723
+ for (const [key, value] of Object.entries(data)) {
724
+ if (key.startsWith("@")) continue;
725
+ if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
726
+ }
727
+ return Object.keys(hrefs).length > 0 ? hrefs : void 0;
728
+ } catch {
729
+ return;
730
+ }
731
+ }
732
+ /**
673
733
  * Stable query key used by useHydraMetadata.
674
734
  * Export this so that consumers (e.g. SmartCrudPage retry button) can
675
735
  * invalidate exactly the right query without coupling to an internal string.
@@ -683,9 +743,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
683
743
  */
684
744
  async function fetchApiDoc(httpClient) {
685
745
  try {
746
+ const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
686
747
  return {
687
748
  format: "hydra",
688
- doc: await fetchHydraDoc(httpClient, JSONLD_URL)
749
+ doc,
750
+ entrypointHrefs
689
751
  };
690
752
  } catch {}
691
753
  try {
@@ -789,15 +851,19 @@ function useResourceSchema(apiUrl) {
789
851
  error,
790
852
  supportedOperations: []
791
853
  };
792
- const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
854
+ const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
793
855
  const normalizedInput = normalizeUrl(apiUrl);
794
856
  const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
795
- if (!resourceSchema) return {
796
- fields: [],
797
- isLoading: false,
798
- error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc`),
799
- supportedOperations: []
800
- };
857
+ if (!resourceSchema) {
858
+ const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
859
+ const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
860
+ return {
861
+ fields: [],
862
+ isLoading: false,
863
+ error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc.` + (slugMatch ? ` Did you mean ${slugMatch}? If your backend generates underscore paths, configure API Platform's dash generator (path_segment_name_generator: api_platform.metadata.path_segment_name_generator.dash) or update the frontend resource path to match.` : "") + ` Known resource URLs: ${knownUrls.join(", ") || "(none)"}`),
864
+ supportedOperations: []
865
+ };
866
+ }
801
867
  return {
802
868
  fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
803
869
  isLoading: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubitio/hydra",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Hydra / OpenAPI adapter for @nubitio/crud — automatic schema discovery and data source from API Platform docs.",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@
50
50
  "react": "^19.0.0",
51
51
  "react-dom": "^19.0.0",
52
52
  "react-i18next": "^14.0.0",
53
- "@nubitio/core": "^0.2.1",
54
- "@nubitio/crud": "^0.2.1"
53
+ "@nubitio/core": "^0.3.1",
54
+ "@nubitio/crud": "^0.3.1"
55
55
  }
56
56
  }