@schafevormfenster/rest-commons 0.1.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.
Files changed (264) hide show
  1. package/CONTRIBUTING.md +1190 -0
  2. package/README.md +275 -0
  3. package/bin/setup.js +10 -0
  4. package/dist/api-schemas/error.schema.d.ts +20 -0
  5. package/dist/api-schemas/error.schema.d.ts.map +1 -0
  6. package/dist/api-schemas/error.schema.js +17 -0
  7. package/dist/api-schemas/health.schema.d.ts +497 -0
  8. package/dist/api-schemas/health.schema.d.ts.map +1 -0
  9. package/dist/api-schemas/health.schema.js +33 -0
  10. package/dist/api-schemas/okay.schema.d.ts +13 -0
  11. package/dist/api-schemas/okay.schema.d.ts.map +1 -0
  12. package/dist/api-schemas/okay.schema.js +5 -0
  13. package/dist/api-schemas/paginated-results.schema.d.ts +59 -0
  14. package/dist/api-schemas/paginated-results.schema.d.ts.map +1 -0
  15. package/dist/api-schemas/paginated-results.schema.js +10 -0
  16. package/dist/api-schemas/partial-results.schema.d.ts +30 -0
  17. package/dist/api-schemas/partial-results.schema.d.ts.map +1 -0
  18. package/dist/api-schemas/partial-results.schema.js +10 -0
  19. package/dist/api-schemas/result.schema.d.ts +17 -0
  20. package/dist/api-schemas/result.schema.d.ts.map +1 -0
  21. package/dist/api-schemas/result.schema.js +5 -0
  22. package/dist/api-schemas/results.schema.d.ts +21 -0
  23. package/dist/api-schemas/results.schema.d.ts.map +1 -0
  24. package/dist/api-schemas/results.schema.js +5 -0
  25. package/dist/helpers/correlation/get-correlation-id.d.ts +7 -0
  26. package/dist/helpers/correlation/get-correlation-id.d.ts.map +1 -0
  27. package/dist/helpers/correlation/get-correlation-id.js +16 -0
  28. package/dist/helpers/correlation/get-header.d.ts +7 -0
  29. package/dist/helpers/correlation/get-header.d.ts.map +1 -0
  30. package/dist/helpers/correlation/get-header.js +11 -0
  31. package/dist/helpers/detect-mime-type.d.ts +11 -0
  32. package/dist/helpers/detect-mime-type.d.ts.map +1 -0
  33. package/dist/helpers/detect-mime-type.js +40 -0
  34. package/dist/helpers/detect-suspicious-patterns.d.ts +8 -0
  35. package/dist/helpers/detect-suspicious-patterns.d.ts.map +1 -0
  36. package/dist/helpers/detect-suspicious-patterns.js +55 -0
  37. package/dist/helpers/eventify-constants.types.d.ts +32 -0
  38. package/dist/helpers/eventify-constants.types.d.ts.map +1 -0
  39. package/dist/helpers/eventify-constants.types.js +40 -0
  40. package/dist/helpers/hash-binary.d.ts +21 -0
  41. package/dist/helpers/hash-binary.d.ts.map +1 -0
  42. package/dist/helpers/hash-binary.js +28 -0
  43. package/dist/helpers/mime-types/detect-image-mime-type.d.ts +5 -0
  44. package/dist/helpers/mime-types/detect-image-mime-type.d.ts.map +1 -0
  45. package/dist/helpers/mime-types/detect-image-mime-type.js +41 -0
  46. package/dist/helpers/mime-types/detect-ole-mime-type.d.ts +6 -0
  47. package/dist/helpers/mime-types/detect-ole-mime-type.d.ts.map +1 -0
  48. package/dist/helpers/mime-types/detect-ole-mime-type.js +34 -0
  49. package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts +5 -0
  50. package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts.map +1 -0
  51. package/dist/helpers/mime-types/detect-pdf-mime-type.js +13 -0
  52. package/dist/helpers/mime-types/detect-zip-mime-type.d.ts +6 -0
  53. package/dist/helpers/mime-types/detect-zip-mime-type.d.ts.map +1 -0
  54. package/dist/helpers/mime-types/detect-zip-mime-type.js +23 -0
  55. package/dist/helpers/parameter-validation.d.ts +6 -0
  56. package/dist/helpers/parameter-validation.d.ts.map +1 -0
  57. package/dist/helpers/parameter-validation.js +19 -0
  58. package/dist/helpers/parameter-validation.types.d.ts +16 -0
  59. package/dist/helpers/parameter-validation.types.d.ts.map +1 -0
  60. package/dist/helpers/parameter-validation.types.js +38 -0
  61. package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts +6 -0
  62. package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts.map +1 -0
  63. package/dist/helpers/response-headers/build-api-unauthorized-headers.js +23 -0
  64. package/dist/helpers/response-headers/environment.types.d.ts +2 -0
  65. package/dist/helpers/response-headers/environment.types.d.ts.map +1 -0
  66. package/dist/helpers/response-headers/environment.types.js +1 -0
  67. package/dist/helpers/response-headers/resolve-environment.d.ts +8 -0
  68. package/dist/helpers/response-headers/resolve-environment.d.ts.map +1 -0
  69. package/dist/helpers/response-headers/resolve-environment.js +18 -0
  70. package/dist/helpers/slugify.d.ts +15 -0
  71. package/dist/helpers/slugify.d.ts.map +1 -0
  72. package/dist/helpers/slugify.js +32 -0
  73. package/dist/index.d.ts +36 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +41 -0
  76. package/dist/normalization/normalize-list.d.ts +11 -0
  77. package/dist/normalization/normalize-list.d.ts.map +1 -0
  78. package/dist/normalization/normalize-list.js +19 -0
  79. package/dist/normalization/normalize-location.d.ts +16 -0
  80. package/dist/normalization/normalize-location.d.ts.map +1 -0
  81. package/dist/normalization/normalize-location.js +26 -0
  82. package/dist/primitives/coordinate-precision.d.ts +10 -0
  83. package/dist/primitives/coordinate-precision.d.ts.map +1 -0
  84. package/dist/primitives/coordinate-precision.js +27 -0
  85. package/dist/primitives/geo-point.schema.d.ts +8 -0
  86. package/dist/primitives/geo-point.schema.d.ts.map +1 -0
  87. package/dist/primitives/geo-point.schema.js +10 -0
  88. package/dist/primitives/geoname-id.schema.d.ts +8 -0
  89. package/dist/primitives/geoname-id.schema.d.ts.map +1 -0
  90. package/dist/primitives/geoname-id.schema.js +9 -0
  91. package/dist/primitives/international-zip.schema.d.ts +76 -0
  92. package/dist/primitives/international-zip.schema.d.ts.map +1 -0
  93. package/dist/primitives/international-zip.schema.js +81 -0
  94. package/dist/primitives/latitude.schema.d.ts +9 -0
  95. package/dist/primitives/latitude.schema.d.ts.map +1 -0
  96. package/dist/primitives/latitude.schema.js +13 -0
  97. package/dist/primitives/location.schema.d.ts +8 -0
  98. package/dist/primitives/location.schema.d.ts.map +1 -0
  99. package/dist/primitives/location.schema.js +15 -0
  100. package/dist/primitives/longitude.schema.d.ts +9 -0
  101. package/dist/primitives/longitude.schema.d.ts.map +1 -0
  102. package/dist/primitives/longitude.schema.js +13 -0
  103. package/dist/primitives/numeric-id.schema.d.ts +8 -0
  104. package/dist/primitives/numeric-id.schema.d.ts.map +1 -0
  105. package/dist/primitives/numeric-id.schema.js +10 -0
  106. package/dist/primitives/slug.schema.d.ts +17 -0
  107. package/dist/primitives/slug.schema.d.ts.map +1 -0
  108. package/dist/primitives/slug.schema.js +30 -0
  109. package/dist/primitives/uuid.schema.d.ts +8 -0
  110. package/dist/primitives/uuid.schema.d.ts.map +1 -0
  111. package/dist/primitives/uuid.schema.js +9 -0
  112. package/dist/primitives/wikidata-id.schema.d.ts +9 -0
  113. package/dist/primitives/wikidata-id.schema.d.ts.map +1 -0
  114. package/dist/primitives/wikidata-id.schema.js +10 -0
  115. package/dist/time/boundary-enforcement.d.ts +11 -0
  116. package/dist/time/boundary-enforcement.d.ts.map +1 -0
  117. package/dist/time/boundary-enforcement.js +43 -0
  118. package/dist/time/bounded-time.schema.d.ts +31 -0
  119. package/dist/time/bounded-time.schema.d.ts.map +1 -0
  120. package/dist/time/bounded-time.schema.js +77 -0
  121. package/dist/time/flexible-time-parser.d.ts +12 -0
  122. package/dist/time/flexible-time-parser.d.ts.map +1 -0
  123. package/dist/time/flexible-time-parser.js +94 -0
  124. package/dist/time/flexible-time.schema.d.ts +31 -0
  125. package/dist/time/flexible-time.schema.d.ts.map +1 -0
  126. package/dist/time/flexible-time.schema.js +31 -0
  127. package/dist/time/get-week-end.d.ts +10 -0
  128. package/dist/time/get-week-end.d.ts.map +1 -0
  129. package/dist/time/get-week-end.js +25 -0
  130. package/dist/time/get-week-start.d.ts +10 -0
  131. package/dist/time/get-week-start.d.ts.map +1 -0
  132. package/dist/time/get-week-start.js +25 -0
  133. package/dist/time/is-relative-time.d.ts +8 -0
  134. package/dist/time/is-relative-time.d.ts.map +1 -0
  135. package/dist/time/is-relative-time.js +9 -0
  136. package/dist/time/iso8601.schema.d.ts +14 -0
  137. package/dist/time/iso8601.schema.d.ts.map +1 -0
  138. package/dist/time/iso8601.schema.js +17 -0
  139. package/dist/time/iso8601.types.d.ts +6 -0
  140. package/dist/time/iso8601.types.d.ts.map +1 -0
  141. package/dist/time/iso8601.types.js +11 -0
  142. package/dist/time/parse-relative-time.d.ts +9 -0
  143. package/dist/time/parse-relative-time.d.ts.map +1 -0
  144. package/dist/time/parse-relative-time.js +36 -0
  145. package/dist/time/relative-time.schema.d.ts +23 -0
  146. package/dist/time/relative-time.schema.d.ts.map +1 -0
  147. package/dist/time/relative-time.schema.js +25 -0
  148. package/dist/time/since-parameter.schema.d.ts +8 -0
  149. package/dist/time/since-parameter.schema.d.ts.map +1 -0
  150. package/dist/time/since-parameter.schema.js +56 -0
  151. package/dist/time/time-helpers.d.ts +19 -0
  152. package/dist/time/time-helpers.d.ts.map +1 -0
  153. package/dist/time/time-helpers.js +56 -0
  154. package/dist/time/time-schemas.d.ts +20 -0
  155. package/dist/time/time-schemas.d.ts.map +1 -0
  156. package/dist/time/time-schemas.js +25 -0
  157. package/dist/time/timezone.types.d.ts +17 -0
  158. package/dist/time/timezone.types.d.ts.map +1 -0
  159. package/dist/time/timezone.types.js +15 -0
  160. package/dist/validation/zod-error-handler.d.ts +3 -0
  161. package/dist/validation/zod-error-handler.d.ts.map +1 -0
  162. package/dist/validation/zod-error-handler.js +189 -0
  163. package/dist/validation/zod-utils.d.ts +9 -0
  164. package/dist/validation/zod-utils.d.ts.map +1 -0
  165. package/dist/validation/zod-utils.js +23 -0
  166. package/eslint.config.mjs +16 -0
  167. package/package.json +44 -0
  168. package/src/api-schemas/error.schema.test.ts +27 -0
  169. package/src/api-schemas/error.schema.ts +23 -0
  170. package/src/api-schemas/health.schema.test.ts +104 -0
  171. package/src/api-schemas/health.schema.ts +63 -0
  172. package/src/api-schemas/okay.schema.test.ts +15 -0
  173. package/src/api-schemas/okay.schema.ts +8 -0
  174. package/src/api-schemas/paginated-results.schema.ts +17 -0
  175. package/src/api-schemas/partial-results.schema.ts +13 -0
  176. package/src/api-schemas/result.schema.test.ts +19 -0
  177. package/src/api-schemas/result.schema.ts +9 -0
  178. package/src/api-schemas/results.schema.test.ts +15 -0
  179. package/src/api-schemas/results.schema.ts +9 -0
  180. package/src/helpers/correlation/get-correlation-id.test.ts +126 -0
  181. package/src/helpers/correlation/get-correlation-id.ts +22 -0
  182. package/src/helpers/correlation/get-header.test.ts +179 -0
  183. package/src/helpers/correlation/get-header.ts +21 -0
  184. package/src/helpers/detect-mime-type.test.ts +100 -0
  185. package/src/helpers/detect-mime-type.ts +46 -0
  186. package/src/helpers/detect-suspicious-patterns.test.ts +45 -0
  187. package/src/helpers/detect-suspicious-patterns.ts +57 -0
  188. package/src/helpers/eventify-constants.test.ts +52 -0
  189. package/src/helpers/eventify-constants.types.test.ts +52 -0
  190. package/src/helpers/eventify-constants.types.ts +51 -0
  191. package/src/helpers/hash-binary.test.ts +60 -0
  192. package/src/helpers/hash-binary.ts +30 -0
  193. package/src/helpers/mime-types/detect-image-mime-type.test.ts +73 -0
  194. package/src/helpers/mime-types/detect-image-mime-type.ts +50 -0
  195. package/src/helpers/mime-types/detect-ole-mime-type.test.ts +86 -0
  196. package/src/helpers/mime-types/detect-ole-mime-type.ts +44 -0
  197. package/src/helpers/mime-types/detect-pdf-mime-type.test.ts +39 -0
  198. package/src/helpers/mime-types/detect-pdf-mime-type.ts +15 -0
  199. package/src/helpers/mime-types/detect-zip-mime-type.test.ts +88 -0
  200. package/src/helpers/mime-types/detect-zip-mime-type.ts +28 -0
  201. package/src/helpers/parameter-validation.test.ts +35 -0
  202. package/src/helpers/parameter-validation.ts +32 -0
  203. package/src/helpers/process-eventify-request.ts +146 -0
  204. package/src/helpers/response-headers/build-api-unauthorized-headers.ts +30 -0
  205. package/src/helpers/response-headers/environment.types.ts +1 -0
  206. package/src/helpers/response-headers/resolve-environment.ts +17 -0
  207. package/src/helpers/slugify.test.ts +77 -0
  208. package/src/helpers/slugify.ts +34 -0
  209. package/src/index.ts +46 -0
  210. package/src/normalization/normalize-list.test.ts +43 -0
  211. package/src/normalization/normalize-list.ts +21 -0
  212. package/src/normalization/normalize-location.test.ts +91 -0
  213. package/src/normalization/normalize-location.ts +29 -0
  214. package/src/primitives/coordinate-precision.test.ts +46 -0
  215. package/src/primitives/coordinate-precision.ts +30 -0
  216. package/src/primitives/geo-point.schema.test.ts +70 -0
  217. package/src/primitives/geo-point.schema.ts +14 -0
  218. package/src/primitives/geoname-id.schema.test.ts +60 -0
  219. package/src/primitives/geoname-id.schema.ts +12 -0
  220. package/src/primitives/international-zip.schema.test.ts +212 -0
  221. package/src/primitives/international-zip.schema.ts +103 -0
  222. package/src/primitives/latitude.schema.test.ts +77 -0
  223. package/src/primitives/latitude.schema.ts +20 -0
  224. package/src/primitives/location.schema.test.ts +21 -0
  225. package/src/primitives/location.schema.ts +22 -0
  226. package/src/primitives/longitude.schema.test.ts +77 -0
  227. package/src/primitives/longitude.schema.ts +20 -0
  228. package/src/primitives/numeric-id.schema.test.ts +32 -0
  229. package/src/primitives/numeric-id.schema.ts +13 -0
  230. package/src/primitives/slug.schema.test.ts +101 -0
  231. package/src/primitives/slug.schema.ts +41 -0
  232. package/src/primitives/uuid.schema.test.ts +45 -0
  233. package/src/primitives/uuid.schema.ts +12 -0
  234. package/src/primitives/wikidata-id.schema.test.ts +51 -0
  235. package/src/primitives/wikidata-id.schema.ts +16 -0
  236. package/src/time/README.md +220 -0
  237. package/src/time/boundary-enforcement.test.ts +130 -0
  238. package/src/time/boundary-enforcement.ts +59 -0
  239. package/src/time/bounded-time.schema.test.ts +294 -0
  240. package/src/time/bounded-time.schema.ts +111 -0
  241. package/src/time/flexible-time-parser.test.ts +586 -0
  242. package/src/time/flexible-time-parser.ts +122 -0
  243. package/src/time/flexible-time.schema.test.ts +243 -0
  244. package/src/time/flexible-time.schema.ts +43 -0
  245. package/src/time/is-relative-time.test.ts +23 -0
  246. package/src/time/is-relative-time.ts +9 -0
  247. package/src/time/iso8601.schema.ts +29 -0
  248. package/src/time/iso8601.types.test.ts +112 -0
  249. package/src/time/iso8601.types.ts +21 -0
  250. package/src/time/parse-relative-time.test.ts +49 -0
  251. package/src/time/parse-relative-time.ts +50 -0
  252. package/src/time/relative-time.schema.test.ts +23 -0
  253. package/src/time/relative-time.schema.ts +38 -0
  254. package/src/time/since-parameter.schema.test.ts +59 -0
  255. package/src/time/since-parameter.schema.ts +69 -0
  256. package/src/time/time-helpers.test.ts +263 -0
  257. package/src/time/time-helpers.ts +78 -0
  258. package/src/time/time-schemas.test.ts +181 -0
  259. package/src/time/time-schemas.ts +42 -0
  260. package/src/time/time.schema.test.ts +237 -0
  261. package/src/time/timezone-independence.test.ts +188 -0
  262. package/src/time/timezone.types.test.ts +55 -0
  263. package/src/time/timezone.types.ts +22 -0
  264. package/tsconfig.json +26 -0
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { SlugSchema, SlugSchemaWithTransform } from "./slug.schema";
4
+
5
+ describe("SlugSchema", () => {
6
+ it("should accept valid slug strings", () => {
7
+ expect(SlugSchema.parse("my-community")).toBe("my-community");
8
+ expect(SlugSchema.parse("test-123")).toBe("test-123");
9
+ expect(SlugSchema.parse("abc-def-ghi")).toBe("abc-def-ghi");
10
+ });
11
+
12
+ it("should accept single hyphen-separated words", () => {
13
+ expect(SlugSchema.parse("ab")).toBe("ab");
14
+ expect(SlugSchema.parse("test-slug")).toBe("test-slug");
15
+ });
16
+
17
+ it("should reject strings shorter than 2 characters", () => {
18
+ expect(() => SlugSchema.parse("a")).toThrow("Slug must be at least 2 characters long");
19
+ });
20
+
21
+ it("should reject strings longer than 150 characters", () => {
22
+ expect(() => SlugSchema.parse("a".repeat(151))).toThrow("Slug must not exceed 150 characters");
23
+ });
24
+
25
+ it("should reject uppercase letters", () => {
26
+ expect(() => SlugSchema.parse("My-Community")).toThrow(
27
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
28
+ );
29
+ expect(() => SlugSchema.parse("TEST")).toThrow(
30
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
31
+ );
32
+ });
33
+
34
+ it("should reject spaces", () => {
35
+ expect(() => SlugSchema.parse("my community")).toThrow(
36
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
37
+ );
38
+ expect(() => SlugSchema.parse("test slug")).toThrow(
39
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
40
+ );
41
+ });
42
+
43
+ it("should reject special characters", () => {
44
+ expect(() => SlugSchema.parse("my_community")).toThrow(
45
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
46
+ );
47
+ expect(() => SlugSchema.parse("test.slug")).toThrow(
48
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
49
+ );
50
+ expect(() => SlugSchema.parse("test@slug")).toThrow(
51
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
52
+ );
53
+ });
54
+
55
+ it("should reject slugs starting or ending with hyphen", () => {
56
+ expect(() => SlugSchema.parse("-my-community")).toThrow(
57
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
58
+ );
59
+ expect(() => SlugSchema.parse("my-community-")).toThrow(
60
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
61
+ );
62
+ });
63
+
64
+ it("should reject consecutive hyphens", () => {
65
+ expect(() => SlugSchema.parse("my--community")).toThrow(
66
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens"
67
+ );
68
+ });
69
+
70
+ it("should reject strings with suspicious patterns", () => {
71
+ expect(() => SlugSchema.parse("community<script>")).toThrow();
72
+ });
73
+ });
74
+
75
+ describe("SlugSchemaWithTransform", () => {
76
+ it("should transform and validate strings into slugs", () => {
77
+ expect(SlugSchemaWithTransform.parse("Hello World")).toBe("hello-world");
78
+ expect(SlugSchemaWithTransform.parse("My Community")).toBe("my-community");
79
+ expect(SlugSchemaWithTransform.parse("Berlin, Germany")).toBe("berlin-germany");
80
+ });
81
+
82
+ it("should handle German umlauts", () => {
83
+ expect(SlugSchemaWithTransform.parse("Café in München")).toBe("cafe-in-muenchen");
84
+ expect(SlugSchemaWithTransform.parse("Äpfel und Öl")).toBe("aepfel-und-oel");
85
+ });
86
+
87
+ it("should handle already valid slugs", () => {
88
+ expect(SlugSchemaWithTransform.parse("my-community")).toBe("my-community");
89
+ expect(SlugSchemaWithTransform.parse("test-slug")).toBe("test-slug");
90
+ });
91
+
92
+ it("should reject slugs that become too short after transformation", () => {
93
+ expect(() => SlugSchemaWithTransform.parse("!")).toThrow();
94
+ });
95
+
96
+ it("should reject slugs that become too long after transformation", () => {
97
+ expect(() => SlugSchemaWithTransform.parse("a".repeat(151))).toThrow(
98
+ "Slug must not exceed 150 characters"
99
+ );
100
+ });
101
+ });
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+
3
+ import { validateNoSuspiciousPatterns } from "../helpers/parameter-validation";
4
+ import { slugify } from "../helpers/slugify";
5
+
6
+ /**
7
+ * Zod schema for validating slug parameter in community slug endpoint.
8
+ * Checks for length between 2-150 characters and suspicious patterns.
9
+ * Enforces lowercase, no spaces, no special characters (only alphanumeric and hyphens).
10
+ */
11
+ export const SlugSchema = z
12
+ .string()
13
+ .min(2, "Slug must be at least 2 characters long")
14
+ .max(150, "Slug must not exceed 150 characters")
15
+ .regex(
16
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
17
+ "Slug must be lowercase, contain only alphanumeric characters and hyphens, and not start or end with a hyphen"
18
+ )
19
+ .refine(
20
+ (value) => {
21
+ validateNoSuspiciousPatterns(value, "slug");
22
+ return true;
23
+ },
24
+ {
25
+ message: "Slug parameter contains suspicious patterns that are not allowed",
26
+ }
27
+ );
28
+
29
+ /**
30
+ * Zod schema that validates and transforms strings into slugs.
31
+ * Automatically converts input to URL-safe format.
32
+ *
33
+ * @example
34
+ * SlugSchemaWithTransform.parse("Hello World!") // "hello-world"
35
+ */
36
+ export const SlugSchemaWithTransform = z
37
+ .string()
38
+ .transform(slugify)
39
+ .pipe(SlugSchema);
40
+
41
+ export type Slug = z.infer<typeof SlugSchema>;
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { UuidSchema } from "./uuid.schema";
4
+
5
+ describe("UuidSchema", () => {
6
+ it("should accept valid UUID v4 strings", () => {
7
+ expect(UuidSchema.parse("550e8400-e29b-41d4-a716-446655440000")).toBe(
8
+ "550e8400-e29b-41d4-a716-446655440000"
9
+ );
10
+ expect(UuidSchema.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(
11
+ "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
12
+ );
13
+ expect(UuidSchema.parse("00000000-0000-0000-0000-000000000000")).toBe(
14
+ "00000000-0000-0000-0000-000000000000"
15
+ );
16
+ });
17
+
18
+ it("should accept UUIDs in uppercase", () => {
19
+ expect(UuidSchema.parse("550E8400-E29B-41D4-A716-446655440000")).toBe(
20
+ "550E8400-E29B-41D4-A716-446655440000"
21
+ );
22
+ });
23
+
24
+ it("should reject invalid UUID formats", () => {
25
+ expect(() => UuidSchema.parse("not-a-uuid")).toThrow("Must be a valid UUID v4");
26
+ expect(() => UuidSchema.parse("550e8400-e29b-41d4-a716")).toThrow("Must be a valid UUID v4");
27
+ expect(() => UuidSchema.parse("550e8400e29b41d4a716446655440000")).toThrow("Must be a valid UUID v4");
28
+ expect(() => UuidSchema.parse("")).toThrow("Must be a valid UUID v4");
29
+ });
30
+
31
+ it("should reject UUIDs with invalid characters", () => {
32
+ expect(() => UuidSchema.parse("550e8400-e29b-41d4-a716-44665544000g")).toThrow(
33
+ "Must be a valid UUID v4"
34
+ );
35
+ });
36
+
37
+ it("should reject non-string values", () => {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ expect(() => UuidSchema.parse(123 as any)).toThrow();
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-null
41
+ expect(() => UuidSchema.parse(null as any)).toThrow();
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ expect(() => UuidSchema.parse(undefined as any)).toThrow();
44
+ });
45
+ });
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Zod schema for validating UUID v4 format.
5
+ * Follows RFC 4122 specification.
6
+ */
7
+ export const UuidSchema = z
8
+ .string()
9
+ .uuid("Must be a valid UUID v4")
10
+ .describe("UUID v4 format (e.g., '550e8400-e29b-41d4-a716-446655440000')");
11
+
12
+ export type Uuid = z.infer<typeof UuidSchema>;
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { WikidataIdSchema } from "./wikidata-id.schema";
4
+
5
+ describe("WikidataIdSchema", () => {
6
+ it("should accept valid Wikidata QIDs", () => {
7
+ expect(WikidataIdSchema.parse("Q1")).toBe("Q1"); // Universe
8
+ expect(WikidataIdSchema.parse("Q64")).toBe("Q64"); // Berlin
9
+ expect(WikidataIdSchema.parse("Q60")).toBe("Q60"); // New York
10
+ expect(WikidataIdSchema.parse("Q123456789")).toBe("Q123456789");
11
+ });
12
+
13
+ it("should reject QIDs starting with Q0", () => {
14
+ expect(() => WikidataIdSchema.parse("Q0")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
15
+ expect(() => WikidataIdSchema.parse("Q01")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
16
+ expect(() => WikidataIdSchema.parse("Q012")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
17
+ });
18
+
19
+ it("should reject QIDs without the Q prefix", () => {
20
+ expect(() => WikidataIdSchema.parse("64")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
21
+ expect(() => WikidataIdSchema.parse("1")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
22
+ });
23
+
24
+ it("should reject lowercase q", () => {
25
+ expect(() => WikidataIdSchema.parse("q64")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
26
+ expect(() => WikidataIdSchema.parse("q1")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
27
+ });
28
+
29
+ it("should reject QIDs with non-numeric characters", () => {
30
+ expect(() => WikidataIdSchema.parse("Q64a")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
31
+ expect(() => WikidataIdSchema.parse("Qabc")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
32
+ expect(() => WikidataIdSchema.parse("Q6-4")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
33
+ });
34
+
35
+ it("should reject empty Q", () => {
36
+ expect(() => WikidataIdSchema.parse("Q")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
37
+ });
38
+
39
+ it("should reject empty strings", () => {
40
+ expect(() => WikidataIdSchema.parse("")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
41
+ });
42
+
43
+ it("should reject non-string values", () => {
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ expect(() => WikidataIdSchema.parse(64 as any)).toThrow();
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-null
47
+ expect(() => WikidataIdSchema.parse(null as any)).toThrow();
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ expect(() => WikidataIdSchema.parse(undefined as any)).toThrow();
50
+ });
51
+ });
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Zod schema for validating Wikidata QID format.
5
+ * QIDs are unique identifiers for entities in Wikidata.
6
+ * Format: Q followed by one or more digits (e.g., "Q64" for Berlin)
7
+ */
8
+ export const WikidataIdSchema = z
9
+ .string()
10
+ .regex(
11
+ /^Q[1-9]\d*$/,
12
+ "Must be a valid Wikidata QID (e.g., 'Q64')"
13
+ )
14
+ .describe("Wikidata entity identifier (QID)");
15
+
16
+ export type WikidataId = z.infer<typeof WikidataIdSchema>;
@@ -0,0 +1,220 @@
1
+ # Time Schema Architecture
2
+
3
+ This document describes the unified time schema architecture for flexible and type-safe time handling.
4
+
5
+ ## Overview
6
+
7
+ The time schema module provides a comprehensive, composable set of Zod schemas for handling time inputs in various formats. It supports:
8
+
9
+ - **ISO8601 timestamps**: Standard datetime strings
10
+ - **Relative times**: Human-readable time expressions like "1d", "3h", "30m"
11
+ - **Flexible times**: Either ISO8601 or relative time
12
+ - **Bounded times**: Min/max constraints on time values
13
+ - **Direction-aware**: Parse relative times as past or future
14
+
15
+ ## Core Schemas
16
+
17
+ ### Validation-Only Schemas
18
+
19
+ These schemas validate format but don't transform the value:
20
+
21
+ ```typescript
22
+ import { ISO8601Schema } from './iso8601.schema';
23
+ import { RelativeTimeSchema } from './relative-time.schema';
24
+ import { FlexibleTimeSchema } from './flexible-time.schema';
25
+
26
+ // Validates ISO8601 format
27
+ ISO8601Schema.parse("2024-01-15T12:00:00Z"); // ✓ returns same string
28
+
29
+ // Validates relative time format
30
+ RelativeTimeSchema.parse("1d"); // ✓ returns "1d"
31
+
32
+ // Validates either format
33
+ FlexibleTimeSchema.parse("2024-01-15T12:00:00Z"); // ✓
34
+ FlexibleTimeSchema.parse("3h"); // ✓
35
+ ```
36
+
37
+ ### Transformation Schemas
38
+
39
+ These schemas validate AND transform to ISO8601:
40
+
41
+ ```typescript
42
+ import {
43
+ RelativeTimePastSchema,
44
+ RelativeTimeFutureSchema,
45
+ } from './relative-time.schema';
46
+ import {
47
+ FlexibleTimePastSchema,
48
+ FlexibleTimeFutureSchema,
49
+ } from './flexible-time.schema';
50
+
51
+ // Transform relative time to ISO8601 (subtracting from now)
52
+ RelativeTimePastSchema.parse("1d");
53
+ // → "2024-01-14T12:00:00.000Z" (if now is 2024-01-15T12:00:00Z)
54
+
55
+ // Transform relative time to ISO8601 (adding to now)
56
+ RelativeTimeFutureSchema.parse("2h");
57
+ // → "2024-01-15T14:00:00.000Z"
58
+
59
+ // Transform flexible time (past direction for relatives)
60
+ FlexibleTimePastSchema.parse("1d"); // → "2024-01-14T12:00:00.000Z"
61
+ FlexibleTimePastSchema.parse("2024-01-15T12:00:00Z"); // → "2024-01-15T12:00:00.000Z"
62
+ ```
63
+
64
+ ## Bounded Schemas
65
+
66
+ Create schemas with min/max time boundaries:
67
+
68
+ ### Flexible Time with Boundaries
69
+
70
+ ```typescript
71
+ import { createBoundedFlexibleTimeSchema } from './bounded-time.schema';
72
+
73
+ // Accept times between 1 week ago and 1 month in future
74
+ const schema = createBoundedFlexibleTimeSchema({
75
+ min: "1w", // Oldest allowed: 1 week ago
76
+ max: "1M", // Newest allowed: 1 month in future
77
+ });
78
+
79
+ schema.parse("3d"); // ✓ (3 days ago, within range)
80
+ schema.parse("2w"); // ✗ (too old, more than 1w ago)
81
+ schema.parse("2024-01-20T12:00:00Z"); // ✓ (if within range)
82
+ ```
83
+
84
+ ### Relative Time with Boundaries
85
+
86
+ ```typescript
87
+ import { createBoundedRelativeTimeSchema } from './bounded-time.schema';
88
+
89
+ // Accept relative times between 1 hour and 30 days ago
90
+ const schema = createBoundedRelativeTimeSchema({
91
+ min: "1h", // Most recent: 1 hour ago
92
+ max: "30d", // Oldest: 30 days ago
93
+ });
94
+
95
+ schema.parse("3d"); // ✓ (3 days ago, within range)
96
+ schema.parse("30m"); // ✗ (too recent, less than 1h ago)
97
+ schema.parse("60d"); // ✗ (too old, more than 30d ago)
98
+ ```
99
+
100
+ ## Helper Functions
101
+
102
+ ### parseRelativeTime
103
+
104
+ Parse relative time expression to ISO8601:
105
+
106
+ ```typescript
107
+ import { parseRelativeTime } from './time-helpers';
108
+
109
+ // Parse relative to a specific date
110
+ const base = new Date("2024-01-15T12:00:00Z");
111
+
112
+ parseRelativeTime("1d", base, "past");
113
+ // → "2024-01-14T12:00:00.000Z"
114
+
115
+ parseRelativeTime("2h", base, "future");
116
+ // → "2024-01-15T14:00:00.000Z"
117
+ ```
118
+
119
+ ### parseFlexibleTime
120
+
121
+ Parse flexible time (ISO8601 or relative) to ISO8601:
122
+
123
+ ```typescript
124
+ import { parseFlexibleTime } from './time-helpers';
125
+
126
+ parseFlexibleTime("2024-01-15T12:00:00Z");
127
+ // → "2024-01-15T12:00:00.000Z"
128
+
129
+ parseFlexibleTime("1d", new Date(), "past");
130
+ // → ISO8601 string for 1 day ago
131
+ ```
132
+
133
+ ## Usage Examples
134
+
135
+ ### API Query Parameter
136
+
137
+ ```typescript
138
+ import { FlexibleTimePastSchema } from './flexible-time.schema';
139
+
140
+ const QuerySchema = z.object({
141
+ since: FlexibleTimePastSchema.describe("Start time for query"),
142
+ until: FlexibleTimePastSchema.optional(),
143
+ });
144
+
145
+ // Accepts both:
146
+ // ?since=2024-01-15T12:00:00Z
147
+ // ?since=3d (3 days ago)
148
+ ```
149
+
150
+ ### Bounded Time Range
151
+
152
+ ```typescript
153
+ import { createBoundedFlexibleTimeSchema } from './bounded-time.schema';
154
+
155
+ const UpdateQuerySchema = z.object({
156
+ updatedAfter: createBoundedFlexibleTimeSchema({
157
+ min: "1w", // Max 1 week in the past
158
+ max: "0h", // Up to now
159
+ }),
160
+ });
161
+ ```
162
+
163
+ ### Log Retention Filter
164
+
165
+ ```typescript
166
+ import { createBoundedRelativeTimeSchema } from './bounded-time.schema';
167
+
168
+ const LogQuerySchema = z.object({
169
+ since: createBoundedRelativeTimeSchema({
170
+ min: "1h", // At least 1 hour ago
171
+ max: "90d", // At most 90 days ago
172
+ }),
173
+ });
174
+ ```
175
+
176
+ ## Type Definitions
177
+
178
+ ```typescript
179
+ // Core types
180
+ type ISO8601 = string; // ISO8601 datetime string
181
+ type RelativeTime = string; // e.g., "1d", "3h", "30m"
182
+ type FlexibleTime = ISO8601 | RelativeTime;
183
+
184
+ // Boundary options
185
+ interface BoundaryOptions {
186
+ min?: string; // Minimum allowed time (relative or ISO)
187
+ max?: string; // Maximum allowed time (relative or ISO)
188
+ }
189
+ }
190
+ ```
191
+
192
+ ## Time Units
193
+
194
+ Supported time units in relative time expressions:
195
+
196
+ - `m`: minutes (e.g., "30m" = 30 minutes)
197
+ - `h`: hours (e.g., "2h" = 2 hours)
198
+ - `d`: days (e.g., "3d" = 3 days)
199
+ - `w`: weeks (e.g., "1w" = 1 week)
200
+ - `M`: months (e.g., "2M" = 2 months, using calendar months)
201
+
202
+ ## Best Practices
203
+
204
+ 1. **Use transformation schemas for API inputs**: They automatically convert to ISO8601
205
+ 2. **Use bounded schemas for security**: Prevent excessive historical or future queries
206
+ 3. **Prefer FlexibleTime for user inputs**: Allows both absolute and relative times
207
+ 4. **Use RelativeTime for time-based rules**: When only relative expressions make sense
208
+ 5. **Add clear error messages**: Help users understand time constraints
209
+ 6. **Import from atomic files**: More efficient bundling and clearer dependencies
210
+
211
+ ## Dependencies
212
+
213
+ - **dayjs**: Lightweight date manipulation library
214
+ - **zod**: TypeScript-first schema validation
215
+
216
+ ## See Also
217
+
218
+ - `flexible-time-parser.ts`: Advanced parser with week notation support
219
+ - `boundary-enforcement.ts`: Helper for clamping times to boundaries
220
+ - `iso8601.types.ts`: Legacy ISO8601 schema (deprecated)
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { clampToBoundaries } from "./boundary-enforcement";
4
+
5
+ describe("clampToBoundaries", () => {
6
+ const now = new Date("2024-01-15T12:00:00.000Z");
7
+
8
+ describe("within boundaries", () => {
9
+ it("should not clamp timestamp within boundaries (-1w to +12m)", () => {
10
+ const timestamp = "2024-01-10T12:00:00.000Z"; // 5 days ago
11
+ const result = clampToBoundaries(timestamp, "testField", now);
12
+
13
+ expect(result.clamped).toBe(timestamp);
14
+ expect(result.wasClamped).toBe(false);
15
+ expect(result.originalValue).toBe(timestamp);
16
+ });
17
+
18
+ it("should not clamp timestamp at minimum boundary (-1w)", () => {
19
+ const timestamp = "2024-01-08T12:00:00.000Z"; // Exactly 1 week ago
20
+ const result = clampToBoundaries(timestamp, "testField", now);
21
+
22
+ expect(result.clamped).toBe(timestamp);
23
+ expect(result.wasClamped).toBe(false);
24
+ });
25
+
26
+ it("should not clamp timestamp at maximum boundary (+12m)", () => {
27
+ const timestamp = "2025-01-15T12:00:00.000Z"; // Exactly 12 months in future
28
+ const result = clampToBoundaries(timestamp, "testField", now);
29
+
30
+ expect(result.clamped).toBe(timestamp);
31
+ expect(result.wasClamped).toBe(false);
32
+ });
33
+
34
+ it("should not clamp timestamp at current time", () => {
35
+ const timestamp = now.toISOString();
36
+ const result = clampToBoundaries(timestamp, "testField", now);
37
+
38
+ expect(result.clamped).toBe(timestamp);
39
+ expect(result.wasClamped).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("minimum boundary clamping", () => {
44
+ it("should clamp timestamp before minimum boundary (-1w)", () => {
45
+ const timestamp = "2024-01-01T12:00:00.000Z"; // 2 weeks ago
46
+ const result = clampToBoundaries(timestamp, "testField", now);
47
+
48
+ expect(result.wasClamped).toBe(true);
49
+ expect(result.originalValue).toBe(timestamp);
50
+ expect(new Date(result.clamped).getTime()).toBeGreaterThan(
51
+ new Date(timestamp).getTime()
52
+ );
53
+ expect(result.clamped).toBe("2024-01-08T12:00:00.000Z"); // Clamped to 1 week ago
54
+ });
55
+
56
+ it("should clamp very old timestamp to minimum boundary", () => {
57
+ const timestamp = "2020-01-01T12:00:00.000Z"; // Years ago
58
+ const result = clampToBoundaries(timestamp, "testField", now);
59
+
60
+ expect(result.wasClamped).toBe(true);
61
+ expect(result.clamped).toBe("2024-01-08T12:00:00.000Z");
62
+ });
63
+ });
64
+
65
+ describe("maximum boundary clamping", () => {
66
+ it("should clamp timestamp after maximum boundary (+12m)", () => {
67
+ const timestamp = "2025-02-15T12:00:00.000Z"; // 13 months in future
68
+ const result = clampToBoundaries(timestamp, "testField", now);
69
+
70
+ expect(result.wasClamped).toBe(true);
71
+ expect(result.originalValue).toBe(timestamp);
72
+ expect(new Date(result.clamped).getTime()).toBeLessThan(
73
+ new Date(timestamp).getTime()
74
+ );
75
+ expect(result.clamped).toBe("2025-01-15T12:00:00.000Z"); // Clamped to 12 months ahead
76
+ });
77
+
78
+ it("should clamp far future timestamp to maximum boundary", () => {
79
+ const timestamp = "2030-01-01T12:00:00.000Z"; // Years in future
80
+ const result = clampToBoundaries(timestamp, "testField", now);
81
+
82
+ expect(result.wasClamped).toBe(true);
83
+ expect(result.clamped).toBe("2025-01-15T12:00:00.000Z");
84
+ });
85
+ });
86
+
87
+ describe("default parameters", () => {
88
+ it("should use current time when 'now' is not provided", () => {
89
+ const timestamp = "2024-01-10T12:00:00.000Z";
90
+ const result = clampToBoundaries(timestamp, "testField");
91
+
92
+ expect(result).toHaveProperty("clamped");
93
+ expect(result).toHaveProperty("wasClamped");
94
+ expect(result).toHaveProperty("originalValue");
95
+ });
96
+ });
97
+
98
+ describe("edge cases", () => {
99
+ it("should handle timestamps with milliseconds", () => {
100
+ const timestamp = "2024-01-10T12:00:00.123Z";
101
+ const result = clampToBoundaries(timestamp, "testField", now);
102
+
103
+ expect(result.wasClamped).toBe(false);
104
+ expect(result.clamped).toBe(timestamp);
105
+ });
106
+
107
+ it("should handle timestamps in different formats", () => {
108
+ const timestamp = "2024-01-10T12:00:00+00:00";
109
+ const result = clampToBoundaries(timestamp, "testField", now);
110
+
111
+ expect(result.wasClamped).toBe(false);
112
+ expect(new Date(result.clamped).getTime()).toBe(
113
+ new Date(timestamp).getTime()
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("field name parameter", () => {
119
+ it("should accept any field name for logging purposes", () => {
120
+ const timestamp = "2024-01-01T12:00:00.000Z";
121
+ const result = clampToBoundaries(
122
+ timestamp,
123
+ "customFieldName",
124
+ now
125
+ );
126
+
127
+ expect(result.wasClamped).toBe(true);
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,59 @@
1
+ import { getLogger } from "@schafevormfenster/logging";
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+
5
+ dayjs.extend(utc);
6
+
7
+ const log = getLogger("helpers.time.boundary-enforcement");
8
+
9
+ /**
10
+ * Helper function to clamp a timestamp to the allowed boundaries (-1w to +12m from now)
11
+ * Returns the clamped ISO8601 timestamp and a boolean indicating if clamping occurred
12
+ * All calculations are performed in UTC timezone
13
+ */
14
+ export function clampToBoundaries(
15
+ timestamp: string,
16
+ fieldName: string,
17
+ now: Date = new Date()
18
+ ): { clamped: string; wasClamped: boolean; originalValue: string } {
19
+ const timestampMs = new Date(timestamp).getTime();
20
+
21
+ // Calculate boundaries using Day.js in UTC
22
+ const minBoundaryMs = dayjs.utc(now).subtract(1, "week").valueOf();
23
+ const maxBoundaryMs = dayjs.utc(now).add(12, "month").valueOf();
24
+
25
+ let clampedMs = timestampMs;
26
+ let wasClamped = false;
27
+
28
+ if (timestampMs < minBoundaryMs) {
29
+ clampedMs = minBoundaryMs;
30
+ wasClamped = true;
31
+ log.warn(
32
+ {
33
+ field: fieldName,
34
+ originalValue: timestamp,
35
+ clampedValue: new Date(clampedMs).toISOString(),
36
+ boundary: "minimum (-1w)",
37
+ },
38
+ `Calendar update query ${fieldName} clamped to minimum boundary`
39
+ );
40
+ } else if (timestampMs > maxBoundaryMs) {
41
+ clampedMs = maxBoundaryMs;
42
+ wasClamped = true;
43
+ log.warn(
44
+ {
45
+ field: fieldName,
46
+ originalValue: timestamp,
47
+ clampedValue: new Date(clampedMs).toISOString(),
48
+ boundary: "maximum (+12m)",
49
+ },
50
+ `Calendar update query ${fieldName} clamped to maximum boundary`
51
+ );
52
+ }
53
+
54
+ return {
55
+ clamped: new Date(clampedMs).toISOString(),
56
+ wasClamped,
57
+ originalValue: timestamp,
58
+ };
59
+ }