fork of hey-api/openapi-ts because I need some additional things

feat: Add bulk callback support for patch.operations

- Update type definition to support both Record and bulk callback function
- Implement bulk callback iteration for OpenAPI v2 and v3
- Add comprehensive tests for bulk callback functionality
- Support async operations for both patterns
- All tests passing (61/61)

Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>

+589 -33
+55 -11
packages/shared/src/config/parser/patch.ts
··· 60 60 meta: OpenApiMetaObject.V2_0_X | OpenApiMetaObject.V3_0_X | OpenApiMetaObject.V3_1_X, 61 61 ) => void; 62 62 /** 63 - * Patch OpenAPI operations in place. The key is the operation method and operation path, and the function receives the operation object to modify directly. 63 + * Patch OpenAPI operations in place. Each function receives the operation 64 + * object to be modified in place. Common use cases include injecting 65 + * `operationId` for specs that don't have them, adding `x-*` extensions, 66 + * setting `deprecated` based on path patterns, or injecting `security` 67 + * requirements globally. 68 + * 69 + * Can be: 70 + * - `Record<string, fn>`: Patch specific operations by `"METHOD /path"` key 71 + * - `function`: Bulk callback receives `(method, path, operation)` for every operation 72 + * 73 + * Both patterns support async functions for operations like fetching data 74 + * from external sources or performing I/O. 64 75 * 65 76 * @example 77 + * ```js 78 + * // Named operations 66 79 * operations: { 67 80 * 'GET /foo': (operation) => { 68 - * operation.responses['200'].description = 'foo'; 81 + * operation.responses['200'].description = 'Success'; 82 + * }, 83 + * 'POST /bar': (operation) => { 84 + * operation.deprecated = true; 69 85 * } 70 86 * } 87 + * 88 + * // Bulk callback for all operations 89 + * operations: (method, path, operation) => { 90 + * if (!operation.operationId) { 91 + * operation.operationId = method + buildOperationName(path); 92 + * } 93 + * } 94 + * 95 + * // Async example - inject operationId based on path patterns 96 + * operations: async (method, path, operation) => { 97 + * if (operation.operationId) return; 98 + * 99 + * const segments = path.split('/').filter(Boolean); 100 + * const parts = segments 101 + * .map((seg) => seg.startsWith('{') ? 'ById' : seg) 102 + * .join(''); 103 + * operation.operationId = method + parts; 104 + * } 105 + * ``` 71 106 */ 72 - operations?: Record< 73 - string, 74 - ( 75 - operation: 76 - | OpenApiOperationObject.V2_0_X 77 - | OpenApiOperationObject.V3_0_X 78 - | OpenApiOperationObject.V3_1_X, 79 - ) => void 80 - >; 107 + operations?: 108 + | Record< 109 + string, 110 + ( 111 + operation: 112 + | OpenApiOperationObject.V2_0_X 113 + | OpenApiOperationObject.V3_0_X 114 + | OpenApiOperationObject.V3_1_X, 115 + ) => void | Promise<void> 116 + > 117 + | (( 118 + method: string, 119 + path: string, 120 + operation: 121 + | OpenApiOperationObject.V2_0_X 122 + | OpenApiOperationObject.V3_0_X 123 + | OpenApiOperationObject.V3_1_X, 124 + ) => void | Promise<void>); 81 125 /** 82 126 * Patch OpenAPI parameters in place. The key is the parameter name, and the function receives the parameter object to modify directly. 83 127 *
+468
packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts
··· 1586 1586 }); 1587 1587 }); 1588 1588 }); 1589 + 1590 + describe('patch.operations', () => { 1591 + describe('OpenAPI v3', () => { 1592 + it('bulk callback mutates all operations', async () => { 1593 + const spec: OpenApi.V3_1_X = { 1594 + ...specMetadataV3, 1595 + paths: { 1596 + '/bar': { 1597 + post: { 1598 + responses: {}, 1599 + } as any, 1600 + }, 1601 + '/foo': { 1602 + get: { 1603 + responses: {}, 1604 + } as any, 1605 + put: { 1606 + responses: {}, 1607 + } as any, 1608 + }, 1609 + }, 1610 + }; 1611 + 1612 + await patchOpenApiSpec({ 1613 + patchOptions: { 1614 + operations: (method, path, operation) => { 1615 + operation.operationId = `${method}_${path.replace(/\//g, '_')}`; 1616 + }, 1617 + }, 1618 + spec, 1619 + }); 1620 + 1621 + expect(spec.paths!['/foo']?.get?.operationId).toBe('get__foo'); 1622 + expect(spec.paths!['/foo']?.put?.operationId).toBe('put__foo'); 1623 + expect(spec.paths!['/bar']?.post?.operationId).toBe('post__bar'); 1624 + }); 1625 + 1626 + it('bulk callback receives correct parameters', async () => { 1627 + const fn = vi.fn(); 1628 + 1629 + const spec: OpenApi.V3_1_X = { 1630 + ...specMetadataV3, 1631 + paths: { 1632 + '/bar': { 1633 + post: { 1634 + responses: {}, 1635 + } as any, 1636 + }, 1637 + '/foo': { 1638 + get: { 1639 + responses: {}, 1640 + } as any, 1641 + }, 1642 + }, 1643 + }; 1644 + 1645 + await patchOpenApiSpec({ 1646 + patchOptions: { 1647 + operations: fn, 1648 + }, 1649 + spec, 1650 + }); 1651 + 1652 + expect(fn).toHaveBeenCalledTimes(2); 1653 + expect(fn).toHaveBeenCalledWith('get', '/foo', spec.paths!['/foo']?.get); 1654 + expect(fn).toHaveBeenCalledWith('post', '/bar', spec.paths!['/bar']?.post); 1655 + }); 1656 + 1657 + it('bulk callback can inject operationId based on path patterns', async () => { 1658 + const spec: OpenApi.V3_1_X = { 1659 + ...specMetadataV3, 1660 + paths: { 1661 + '/users': { 1662 + get: { 1663 + responses: {}, 1664 + } as any, 1665 + post: { 1666 + responses: {}, 1667 + } as any, 1668 + }, 1669 + '/users/{id}': { 1670 + delete: { 1671 + responses: {}, 1672 + } as any, 1673 + get: { 1674 + operationId: 'existingId', 1675 + responses: {}, 1676 + } as any, 1677 + }, 1678 + }, 1679 + }; 1680 + 1681 + await patchOpenApiSpec({ 1682 + patchOptions: { 1683 + operations: (method, path, operation) => { 1684 + if (operation.operationId) return; // don't override existing 1685 + 1686 + const segments = path.split('/').filter(Boolean); 1687 + const parts = segments.map((seg) => (seg.startsWith('{') ? 'ById' : seg)).join(''); 1688 + operation.operationId = method + parts; 1689 + }, 1690 + }, 1691 + spec, 1692 + }); 1693 + 1694 + expect(spec.paths!['/users']?.get?.operationId).toBe('getusers'); 1695 + expect(spec.paths!['/users']?.post?.operationId).toBe('postusers'); 1696 + expect(spec.paths!['/users/{id}']?.get?.operationId).toBe('existingId'); // not overridden 1697 + expect(spec.paths!['/users/{id}']?.delete?.operationId).toBe('deleteusersById'); 1698 + }); 1699 + 1700 + it('bulk callback skips invalid operations', async () => { 1701 + const fn = vi.fn(); 1702 + 1703 + const spec: OpenApi.V3_1_X = { 1704 + ...specMetadataV3, 1705 + paths: { 1706 + '/bar': { 1707 + get: null as any, 1708 + post: 'invalid' as any, 1709 + }, 1710 + '/baz': 123 as any, 1711 + '/foo': { 1712 + get: { 1713 + responses: {}, 1714 + } as any, 1715 + }, 1716 + }, 1717 + }; 1718 + 1719 + await patchOpenApiSpec({ 1720 + patchOptions: { 1721 + operations: fn, 1722 + }, 1723 + spec, 1724 + }); 1725 + 1726 + expect(fn).toHaveBeenCalledOnce(); 1727 + expect(fn).toHaveBeenCalledWith('get', '/foo', spec.paths!['/foo']?.get); 1728 + }); 1729 + 1730 + it('supports async bulk callback', async () => { 1731 + const spec: OpenApi.V3_1_X = { 1732 + ...specMetadataV3, 1733 + paths: { 1734 + '/bar': { 1735 + post: { 1736 + responses: {}, 1737 + } as any, 1738 + }, 1739 + '/foo': { 1740 + get: { 1741 + responses: {}, 1742 + } as any, 1743 + }, 1744 + }, 1745 + }; 1746 + 1747 + await patchOpenApiSpec({ 1748 + patchOptions: { 1749 + operations: async (method, path, operation) => { 1750 + // Simulate async operation 1751 + await Promise.resolve(); 1752 + operation.operationId = `async_${method}_${path}`; 1753 + }, 1754 + }, 1755 + spec, 1756 + }); 1757 + 1758 + expect(spec.paths!['/foo']?.get?.operationId).toBe('async_get_/foo'); 1759 + expect(spec.paths!['/bar']?.post?.operationId).toBe('async_post_/bar'); 1760 + }); 1761 + 1762 + it('supports async Record-based callbacks', async () => { 1763 + const spec: OpenApi.V3_1_X = { 1764 + ...specMetadataV3, 1765 + paths: { 1766 + '/bar': { 1767 + post: { 1768 + responses: {}, 1769 + } as any, 1770 + }, 1771 + '/foo': { 1772 + get: { 1773 + responses: {}, 1774 + } as any, 1775 + }, 1776 + }, 1777 + }; 1778 + 1779 + await patchOpenApiSpec({ 1780 + patchOptions: { 1781 + operations: { 1782 + 'GET /foo': async (operation) => { 1783 + await Promise.resolve(); 1784 + operation.operationId = 'asyncGetFoo'; 1785 + }, 1786 + 'POST /bar': async (operation) => { 1787 + await Promise.resolve(); 1788 + operation.operationId = 'asyncPostBar'; 1789 + }, 1790 + }, 1791 + }, 1792 + spec, 1793 + }); 1794 + 1795 + expect(spec.paths!['/foo']?.get?.operationId).toBe('asyncGetFoo'); 1796 + expect(spec.paths!['/bar']?.post?.operationId).toBe('asyncPostBar'); 1797 + }); 1798 + 1799 + it('Record-based operations still work as before', async () => { 1800 + const spec: OpenApi.V3_1_X = { 1801 + ...specMetadataV3, 1802 + paths: { 1803 + '/bar': { 1804 + post: { 1805 + responses: {}, 1806 + } as any, 1807 + }, 1808 + '/foo': { 1809 + get: { 1810 + responses: {}, 1811 + } as any, 1812 + }, 1813 + }, 1814 + }; 1815 + 1816 + await patchOpenApiSpec({ 1817 + patchOptions: { 1818 + operations: { 1819 + 'GET /foo': (operation) => { 1820 + operation.operationId = 'getFoo'; 1821 + }, 1822 + 'POST /bar': (operation) => { 1823 + operation.operationId = 'postBar'; 1824 + }, 1825 + }, 1826 + }, 1827 + spec, 1828 + }); 1829 + 1830 + expect(spec.paths!['/foo']?.get?.operationId).toBe('getFoo'); 1831 + expect(spec.paths!['/bar']?.post?.operationId).toBe('postBar'); 1832 + }); 1833 + 1834 + it('handles spec without paths', async () => { 1835 + const fn = vi.fn(); 1836 + 1837 + const spec: OpenApi.V3_1_X = { 1838 + ...specMetadataV3, 1839 + }; 1840 + 1841 + await patchOpenApiSpec({ 1842 + patchOptions: { 1843 + operations: fn, 1844 + }, 1845 + spec, 1846 + }); 1847 + 1848 + expect(fn).not.toHaveBeenCalled(); 1849 + }); 1850 + 1851 + it('handles all HTTP methods', async () => { 1852 + const fn = vi.fn(); 1853 + 1854 + const spec: OpenApi.V3_1_X = { 1855 + ...specMetadataV3, 1856 + paths: { 1857 + '/test': { 1858 + delete: { responses: {} } as any, 1859 + get: { responses: {} } as any, 1860 + head: { responses: {} } as any, 1861 + options: { responses: {} } as any, 1862 + patch: { responses: {} } as any, 1863 + post: { responses: {} } as any, 1864 + put: { responses: {} } as any, 1865 + trace: { responses: {} } as any, 1866 + }, 1867 + }, 1868 + }; 1869 + 1870 + await patchOpenApiSpec({ 1871 + patchOptions: { 1872 + operations: fn, 1873 + }, 1874 + spec, 1875 + }); 1876 + 1877 + expect(fn).toHaveBeenCalledTimes(8); 1878 + expect(fn).toHaveBeenCalledWith('get', '/test', expect.any(Object)); 1879 + expect(fn).toHaveBeenCalledWith('put', '/test', expect.any(Object)); 1880 + expect(fn).toHaveBeenCalledWith('post', '/test', expect.any(Object)); 1881 + expect(fn).toHaveBeenCalledWith('delete', '/test', expect.any(Object)); 1882 + expect(fn).toHaveBeenCalledWith('options', '/test', expect.any(Object)); 1883 + expect(fn).toHaveBeenCalledWith('head', '/test', expect.any(Object)); 1884 + expect(fn).toHaveBeenCalledWith('patch', '/test', expect.any(Object)); 1885 + expect(fn).toHaveBeenCalledWith('trace', '/test', expect.any(Object)); 1886 + }); 1887 + }); 1888 + 1889 + describe('OpenAPI v2', () => { 1890 + it('bulk callback mutates all operations', async () => { 1891 + const spec: OpenApi.V2_0_X = { 1892 + ...specMetadataV2, 1893 + paths: { 1894 + '/bar': { 1895 + post: { 1896 + responses: {}, 1897 + } as any, 1898 + }, 1899 + '/foo': { 1900 + get: { 1901 + responses: {}, 1902 + } as any, 1903 + put: { 1904 + responses: {}, 1905 + } as any, 1906 + }, 1907 + }, 1908 + }; 1909 + 1910 + await patchOpenApiSpec({ 1911 + patchOptions: { 1912 + operations: (method, path, operation) => { 1913 + operation.operationId = `${method}_${path.replace(/\//g, '_')}`; 1914 + }, 1915 + }, 1916 + spec, 1917 + }); 1918 + 1919 + expect(spec.paths!['/foo']?.get?.operationId).toBe('get__foo'); 1920 + expect(spec.paths!['/foo']?.put?.operationId).toBe('put__foo'); 1921 + expect(spec.paths!['/bar']?.post?.operationId).toBe('post__bar'); 1922 + }); 1923 + 1924 + it('bulk callback receives correct parameters', async () => { 1925 + const fn = vi.fn(); 1926 + 1927 + const spec: OpenApi.V2_0_X = { 1928 + ...specMetadataV2, 1929 + paths: { 1930 + '/bar': { 1931 + post: { 1932 + responses: {}, 1933 + } as any, 1934 + }, 1935 + '/foo': { 1936 + get: { 1937 + responses: {}, 1938 + } as any, 1939 + }, 1940 + }, 1941 + }; 1942 + 1943 + await patchOpenApiSpec({ 1944 + patchOptions: { 1945 + operations: fn, 1946 + }, 1947 + spec, 1948 + }); 1949 + 1950 + expect(fn).toHaveBeenCalledTimes(2); 1951 + expect(fn).toHaveBeenCalledWith('get', '/foo', spec.paths!['/foo']?.get); 1952 + expect(fn).toHaveBeenCalledWith('post', '/bar', spec.paths!['/bar']?.post); 1953 + }); 1954 + 1955 + it('supports async bulk callback', async () => { 1956 + const spec: OpenApi.V2_0_X = { 1957 + ...specMetadataV2, 1958 + paths: { 1959 + '/bar': { 1960 + post: { 1961 + responses: {}, 1962 + } as any, 1963 + }, 1964 + '/foo': { 1965 + get: { 1966 + responses: {}, 1967 + } as any, 1968 + }, 1969 + }, 1970 + }; 1971 + 1972 + await patchOpenApiSpec({ 1973 + patchOptions: { 1974 + operations: async (method, path, operation) => { 1975 + await Promise.resolve(); 1976 + operation.operationId = `async_${method}_${path}`; 1977 + }, 1978 + }, 1979 + spec, 1980 + }); 1981 + 1982 + expect(spec.paths!['/foo']?.get?.operationId).toBe('async_get_/foo'); 1983 + expect(spec.paths!['/bar']?.post?.operationId).toBe('async_post_/bar'); 1984 + }); 1985 + 1986 + it('Record-based operations still work as before', async () => { 1987 + const spec: OpenApi.V2_0_X = { 1988 + ...specMetadataV2, 1989 + paths: { 1990 + '/bar': { 1991 + post: { 1992 + responses: {}, 1993 + } as any, 1994 + }, 1995 + '/foo': { 1996 + get: { 1997 + responses: {}, 1998 + } as any, 1999 + }, 2000 + }, 2001 + }; 2002 + 2003 + await patchOpenApiSpec({ 2004 + patchOptions: { 2005 + operations: { 2006 + 'GET /foo': (operation) => { 2007 + operation.operationId = 'getFoo'; 2008 + }, 2009 + 'POST /bar': (operation) => { 2010 + operation.operationId = 'postBar'; 2011 + }, 2012 + }, 2013 + }, 2014 + spec, 2015 + }); 2016 + 2017 + expect(spec.paths!['/foo']?.get?.operationId).toBe('getFoo'); 2018 + expect(spec.paths!['/bar']?.post?.operationId).toBe('postBar'); 2019 + }); 2020 + 2021 + it('handles all HTTP methods', async () => { 2022 + const fn = vi.fn(); 2023 + 2024 + const spec: OpenApi.V2_0_X = { 2025 + ...specMetadataV2, 2026 + paths: { 2027 + '/test': { 2028 + delete: { responses: {} } as any, 2029 + get: { responses: {} } as any, 2030 + head: { responses: {} } as any, 2031 + options: { responses: {} } as any, 2032 + patch: { responses: {} } as any, 2033 + post: { responses: {} } as any, 2034 + put: { responses: {} } as any, 2035 + }, 2036 + }, 2037 + }; 2038 + 2039 + await patchOpenApiSpec({ 2040 + patchOptions: { 2041 + operations: fn, 2042 + }, 2043 + spec, 2044 + }); 2045 + 2046 + expect(fn).toHaveBeenCalledTimes(7); 2047 + expect(fn).toHaveBeenCalledWith('get', '/test', expect.any(Object)); 2048 + expect(fn).toHaveBeenCalledWith('put', '/test', expect.any(Object)); 2049 + expect(fn).toHaveBeenCalledWith('post', '/test', expect.any(Object)); 2050 + expect(fn).toHaveBeenCalledWith('delete', '/test', expect.any(Object)); 2051 + expect(fn).toHaveBeenCalledWith('options', '/test', expect.any(Object)); 2052 + expect(fn).toHaveBeenCalledWith('head', '/test', expect.any(Object)); 2053 + expect(fn).toHaveBeenCalledWith('patch', '/test', expect.any(Object)); 2054 + }); 2055 + }); 2056 + }); 1589 2057 });
+66 -22
packages/shared/src/openApi/shared/utils/patch.ts
··· 55 55 } 56 56 57 57 if (patchOptions.operations && spec.paths) { 58 - for (const key in patchOptions.operations) { 59 - const [method, path] = key.split(' '); 60 - if (!method || !path) continue; 58 + if (typeof patchOptions.operations === 'function') { 59 + // Bulk callback: iterate all operations 60 + for (const [path, pathItem] of Object.entries(spec.paths)) { 61 + if (!pathItem || typeof pathItem !== 'object') continue; 62 + for (const method of [ 63 + 'get', 64 + 'put', 65 + 'post', 66 + 'delete', 67 + 'options', 68 + 'head', 69 + 'patch', 70 + 'trace', 71 + ]) { 72 + const operation = pathItem[method as keyof typeof pathItem]; 73 + if (!operation || typeof operation !== 'object') continue; 74 + await patchOptions.operations(method, path, operation as any); 75 + } 76 + } 77 + } else { 78 + // Record-based: iterate named operations 79 + for (const key in patchOptions.operations) { 80 + const [method, path] = key.split(' '); 81 + if (!method || !path) continue; 61 82 62 - const pathItem = spec.paths[path as keyof typeof spec.paths]; 63 - if (!pathItem) continue; 83 + const pathItem = spec.paths[path as keyof typeof spec.paths]; 84 + if (!pathItem) continue; 64 85 65 - const operation = 66 - pathItem[method.toLocaleLowerCase() as keyof typeof pathItem] || 67 - pathItem[method.toLocaleUpperCase() as keyof typeof pathItem]; 68 - if (!operation || typeof operation !== 'object') continue; 86 + const operation = 87 + pathItem[method.toLocaleLowerCase() as keyof typeof pathItem] || 88 + pathItem[method.toLocaleUpperCase() as keyof typeof pathItem]; 89 + if (!operation || typeof operation !== 'object') continue; 69 90 70 - const patchFn = patchOptions.operations[key]!; 71 - patchFn(operation as any); 91 + const patchFn = patchOptions.operations[key]!; 92 + await patchFn(operation as any); 93 + } 72 94 } 73 95 } 74 96 return; ··· 137 159 } 138 160 139 161 if (patchOptions.operations && spec.paths) { 140 - for (const key in patchOptions.operations) { 141 - const [method, path] = key.split(' '); 142 - if (!method || !path) continue; 162 + if (typeof patchOptions.operations === 'function') { 163 + // Bulk callback: iterate all operations 164 + for (const [path, pathItem] of Object.entries(spec.paths)) { 165 + if (!pathItem || typeof pathItem !== 'object') continue; 166 + for (const method of [ 167 + 'get', 168 + 'put', 169 + 'post', 170 + 'delete', 171 + 'options', 172 + 'head', 173 + 'patch', 174 + 'trace', 175 + ]) { 176 + const operation = pathItem[method as keyof typeof pathItem]; 177 + if (!operation || typeof operation !== 'object') continue; 178 + await patchOptions.operations(method, path, operation as any); 179 + } 180 + } 181 + } else { 182 + // Record-based: iterate named operations 183 + for (const key in patchOptions.operations) { 184 + const [method, path] = key.split(' '); 185 + if (!method || !path) continue; 143 186 144 - const pathItem = spec.paths[path as keyof typeof spec.paths]; 145 - if (!pathItem) continue; 187 + const pathItem = spec.paths[path as keyof typeof spec.paths]; 188 + if (!pathItem) continue; 146 189 147 - const operation = 148 - pathItem[method.toLocaleLowerCase() as keyof typeof pathItem] || 149 - pathItem[method.toLocaleUpperCase() as keyof typeof pathItem]; 150 - if (!operation || typeof operation !== 'object') continue; 190 + const operation = 191 + pathItem[method.toLocaleLowerCase() as keyof typeof pathItem] || 192 + pathItem[method.toLocaleUpperCase() as keyof typeof pathItem]; 193 + if (!operation || typeof operation !== 'object') continue; 151 194 152 - const patchFn = patchOptions.operations[key]!; 153 - patchFn(operation as any); 195 + const patchFn = patchOptions.operations[key]!; 196 + await patchFn(operation as any); 197 + } 154 198 } 155 199 } 156 200 }