Skip to content

Commit 77cec66

Browse files
committed
fix(windchill): correct OData query encoding, content download, and cleared-field handling
Validated the integration end to end against PTC Windchill REST Services 2.7 documentation and fixed every divergence found. Protocol correctness: - Encode OData query spaces as %20 rather than the form-encoded `+` that URLSearchParams emits. Every multi-token $filter and $orderby reached Windchill as a literal `+` and could not match. - Download content through the documented typed navigation `<content>/PTC.ApplicationData/Content/URL`, which returns a signed vault URL, instead of a `$value` segment that WRS does not implement. The resolved URL is pinned to the configured HTTPS origin. - Terminate every Stage 2 CacheDescriptor_array entry with `;` to match the documented grammar. - Raise the $top bound to Windchill's documented 2000 maximum, keeping 200 as the default page size. Cleared-field handling: - The executor merges raw block inputs before the block's param transform, so omitting a key could not clear it. A cleared numeric or boolean field reached the URL builder as '' and threw, and cleared optional strings failed contract validation. Coercions now emit an explicit undefined, and the internal-route body strips blanks centrally. Robustness and contracts: - Bound the document-structure walk to the depth actually requested. - Loosen response schemas that re-applied request-side bounds to provider-returned values, which turned committed mutations into opaque parse failures. - Return contract-shaped bodies for oversized, malformed, and unhandled request failures. - Normalize downloaded content types and drop charset parameters. Presentation and docs: - Square the icon to a centred tile on white. - Replace WT.Document and PATCH-compatible jargon with plain language. - Fix canvas sentence noun stutters on the bulk operations. - Correct the revision skill's unverified working-copy claim to read the OID back rather than assume it, and add retirement and stale-checkout skills. - Add a manual intro section to the integration docs page.
1 parent 0365309 commit 77cec66

18 files changed

Lines changed: 585 additions & 171 deletions

File tree

apps/docs/components/icons.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,12 +2497,13 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
24972497
export function WindchillIcon(props: SVGProps<SVGSVGElement>) {
24982498
return (
24992499
<svg
2500-
{...props}
2501-
viewBox='87.3 60.2 71.1 82'
2500+
viewBox='79 57 88 88'
25022501
role='img'
25032502
aria-label='Windchill icon'
25042503
xmlns='http://www.w3.org/2000/svg'
2504+
{...props}
25052505
>
2506+
<rect x='79' y='57' width='88' height='88' fill='#FFFFFF' />
25062507
<g fill='#3D4647'>
25072508
<polygon points='137.2,86.6 137.2,68.5 122.8,60.2 107.1,69.2' />
25082509
<polygon points='142.7,106.4 158.4,97.3 158.4,80.7 142.7,71.6' />

apps/docs/content/docs/en/integrations/windchill.mdx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
---
22
title: Windchill
3-
description: Manage WT.Document objects and content in PTC Windchill
3+
description: Manage documents, revisions, and content in PTC Windchill
44
---
55

66
import { BlockInfoCard } from "@/components/ui/block-info-card"
77

8-
<BlockInfoCard
8+
<BlockInfoCard
99
type="windchill"
1010
color="#FFFFFF"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[PTC Windchill](https://www.ptc.com/en/products/windchill) is the product lifecycle management system manufacturers use as the system of record for engineering data. Documents in Windchill are controlled objects: each one carries a number, a revision and iteration, a lifecycle state, folder placement, security labels, and a checkout status that decides who is allowed to change it right now.
15+
16+
This integration talks to Windchill REST Services (WRS) 2.7 over its OData API using a Basic-authenticated service account. Point it at a complete versioned service root — `https://your-host/Windchill/servlet/odata/v6` — and your agents can:
17+
18+
- **Find and read documents**: list documents with an OData filter, sort order, field selection, page size, and a latest-version-only switch; fetch a single document by its object identifier (OID); and walk a document's structure through its usage links to see child documents with their versions and states.
19+
- **Create and update**: create one document or a batch of them in a container and optional folder, and patch installed attributes on one or many documents. Name, Number, and Organization are deliberately rejected — Windchill requires its own `UpdateCommonProperties` action for those.
20+
- **Run the version and lifecycle cycle**: check documents out and back in with notes, undo a checkout, revise to the next revision, read the lifecycle states a document is actually allowed to move to, and transition it to one of them.
21+
- **Move files**: download a document's primary content or a named attachment into a Sim file, and upload files as primary content or attachments through Windchill's three-stage vault upload.
22+
23+
Bulk actions are atomic on Windchill's side: if the action fails for any object in the batch, the whole operation rolls back and nothing changes. Every write first fetches a CSRF nonce and replays it, matching what WRS requires of any REST client.
24+
25+
Two limits are worth knowing before you build. Windchill identifies everything by OID (`OR:wt.doc.WTDocument:48796581`), so most operations need an OID you got from a list or get call rather than a document number. And this integration supports Basic authentication only — Windchill deployments fronted by OAuth are not currently supported.
26+
{/* MANUAL-CONTENT-END */}
27+
28+
1329
## Usage Instructions
1430

15-
Integrate PTC Windchill REST Services 2.7 document management into your workflow using Basic authentication. Read and update WT.Document metadata, perform version and lifecycle actions, and transfer primary content and attachments. Windchill OAuth deployments are not currently supported.
31+
Integrate PTC Windchill REST Services 2.7 document management into your workflow using Basic authentication. Read and update document metadata, perform version and lifecycle actions, and transfer primary content and attachments. Windchill OAuth deployments are not currently supported.
1632

1733

1834

@@ -295,7 +311,7 @@ Create multiple WT.Document objects atomically
295311
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
296312
| `username` | string | Yes | Windchill service-account username |
297313
| `password` | string | Yes | Windchill service-account password |
298-
| `documents` | json | Yes | Document inputs as a JSON array; each item requires name and containerOid |
314+
| `documents` | array | Yes | Document inputs as a JSON array; each item requires name and containerOid |
299315

300316
#### Output
301317

@@ -366,7 +382,7 @@ Update PATCH-compatible attributes on multiple documents atomically
366382
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
367383
| `username` | string | Yes | Windchill service-account username |
368384
| `password` | string | Yes | Windchill service-account password |
369-
| `documents` | json | Yes | Document updates as a JSON array; each item requires id and bounded PATCH-compatible attributes. Name, Number, and Organization are not supported. |
385+
| `documents` | array | Yes | Document updates as a JSON array; each item requires id and bounded PATCH-compatible attributes. Name, Number, and Organization are not supported. |
370386

371387
#### Output
372388

@@ -767,7 +783,7 @@ Update installed security-label attributes for one or more documents
767783
| `baseUrl` | string | Yes | Complete WRS 2.7 versioned service root using Basic authentication, for example https://host/Windchill/servlet/odata/v6 |
768784
| `username` | string | Yes | Windchill service-account username |
769785
| `password` | string | Yes | Windchill service-account password |
770-
| `securityLabelUpdates` | json | Yes | Array of document IDs and installed security-label values |
786+
| `securityLabelUpdates` | array | Yes | Array of document IDs and installed security-label values |
771787

772788
#### Output
773789

@@ -816,7 +832,7 @@ Download primary content into a canonical UserFile
816832

817833
### Windchill Upload Primary Content
818834

819-
Upload or replace a document primary-content file
835+
Upload a primary-content file to a document that has none
820836

821837
#### Input
822838

@@ -835,6 +851,7 @@ Upload or replace a document primary-content file
835851
| `operation` | string | Windchill operation that was executed |
836852
| `affectedIds` | array | Document identifiers affected by the upload |
837853
| `uploadedFileNames` | array | Names of files accepted by Windchill |
854+
838855
### Windchill Download Attachment
839856

840857
Download a document attachment into a canonical UserFile
@@ -880,3 +897,5 @@ Upload one or more files as document attachments
880897
| `operation` | string | Windchill operation that was executed |
881898
| `affectedIds` | array | Document identifiers affected by the upload |
882899
| `uploadedFileNames` | array | Names of files accepted by Windchill |
900+
901+

apps/sim/app/api/tools/windchill/route.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
mockDownloadServableFileFromStorage,
1717
mockDownloadWindchillContent,
1818
mockGetSession,
19+
mockResolveWindchillContentUrl,
1920
mockProcessFilesToUserFiles,
2021
mockUploadCopilotFile,
2122
mockUploadExecutionFile,
@@ -42,6 +43,7 @@ const {
4243
mockDownloadServableFileFromStorage: vi.fn(),
4344
mockDownloadWindchillContent: vi.fn(),
4445
mockGetSession: vi.fn(),
46+
mockResolveWindchillContentUrl: vi.fn(),
4547
mockProcessFilesToUserFiles: vi.fn(),
4648
mockUploadCopilotFile: vi.fn(),
4749
mockUploadExecutionFile: vi.fn(),
@@ -78,6 +80,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => ({
7880
vi.mock('@/tools/windchill/utils.server', () => ({
7981
createWindchillSession: mockCreateWindchillSession,
8082
downloadWindchillContent: mockDownloadWindchillContent,
83+
resolveWindchillContentUrl: mockResolveWindchillContentUrl,
8184
sanitizeWindchillError: (message: string) => message.replace(/https?:\/\/\S+/g, '[redacted URL]'),
8285
uploadWindchillContent: mockUploadWindchillContent,
8386
windchillDocumentUrl: (baseUrl: string, documentOid: string) =>
@@ -302,6 +305,10 @@ beforeEach(() => {
302305
contentType: 'application/pdf',
303306
})
304307
mockUploadWindchillContent.mockResolvedValue(['specification.pdf'])
308+
mockResolveWindchillContentUrl.mockImplementation(
309+
async ({ contentPath }: { contentPath: string }) =>
310+
`https://windchill.example.com/Windchill/servlet/WindchillGW/download?from=${encodeURIComponent(contentPath)}`
311+
)
305312
mockDownloadWindchillContent.mockResolvedValue({
306313
buffer: Buffer.from('pdf'),
307314
contentType: 'application/pdf',
@@ -694,9 +701,15 @@ describe('POST /api/tools/windchill', () => {
694701
const data = await response.json()
695702

696703
expect(response.status).toBe(200)
704+
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
705+
expect.objectContaining({
706+
contentPath: expect.stringContaining('/PrimaryContent'),
707+
})
708+
)
709+
expect(mockResolveWindchillContentUrl.mock.calls[0][0].contentPath).not.toContain('$value')
697710
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
698711
expect.objectContaining({
699-
url: expect.stringContaining('/PrimaryContent/$value'),
712+
url: expect.stringContaining('/WindchillGW/download'),
700713
})
701714
)
702715
expect(mockUploadCopilotFile).toHaveBeenCalledWith(
@@ -722,9 +735,14 @@ describe('POST /api/tools/windchill', () => {
722735
)
723736

724737
expect(response.status).toBe(200)
738+
expect(mockResolveWindchillContentUrl).toHaveBeenCalledWith(
739+
expect.objectContaining({
740+
contentPath: expect.stringContaining("/Attachments('OR%3Awt.content.ApplicationData%3A2')"),
741+
})
742+
)
725743
expect(mockDownloadWindchillContent).toHaveBeenCalledWith(
726744
expect.objectContaining({
727-
url: expect.stringContaining("/Attachments('OR%3Awt.content.ApplicationData%3A2')/$value"),
745+
url: expect.stringContaining('/WindchillGW/download'),
728746
})
729747
)
730748
})

apps/sim/app/api/tools/windchill/route.ts

Lines changed: 89 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import {
3636
createWindchillSession,
3737
downloadWindchillContent,
38+
resolveWindchillContentUrl,
3839
uploadWindchillContent,
3940
WindchillProviderError,
4041
type WindchillUploadFile,
@@ -116,8 +117,12 @@ function documentsById(documentOids: string[]) {
116117
return documentOids.map((ID) => ({ ID }))
117118
}
118119

120+
/** Keeps the media type and drops any `; charset=...` parameters Windchill cannot use. */
119121
function safeMimeType(value: string | undefined): string {
120-
if (value && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(value)) return value
122+
const mediaType = value?.split(';', 1)[0]?.trim()
123+
if (mediaType && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(mediaType)) {
124+
return mediaType
125+
}
121126
return 'application/octet-stream'
122127
}
123128

@@ -506,10 +511,15 @@ async function executeDownload(
506511
signal: AbortSignal
507512
): Promise<WindchillRouteOutput> {
508513
const documentUrl = windchillDocumentUrl(body.baseUrl, body.documentOid)
509-
const contentUrl =
514+
const contentPath =
510515
body.operation === 'windchill_download_primary_content'
511-
? `${documentUrl}/PrimaryContent/$value`
512-
: `${documentUrl}/Attachments('${encodeWindchillOid(body.attachmentOid)}')/$value`
516+
? `${documentUrl}/PrimaryContent`
517+
: `${documentUrl}/Attachments('${encodeWindchillOid(body.attachmentOid)}')`
518+
const contentUrl = await resolveWindchillContentUrl({
519+
params: body,
520+
contentPath,
521+
signal,
522+
})
513523
const downloaded = await downloadWindchillContent({
514524
params: body,
515525
url: contentUrl,
@@ -523,89 +533,98 @@ async function executeDownload(
523533
const fileName = sanitizeFileName(
524534
body.fileName || contentDispositionFileName(downloaded.contentDisposition) || fallback
525535
)
536+
const mimeType = safeMimeType(downloaded.contentType)
526537
const file = await storeDownloadedFile({
527538
principal,
528539
buffer: downloaded.buffer,
529540
fileName,
530-
contentType: downloaded.contentType,
541+
contentType: mimeType,
531542
})
532543
return {
533544
operation: body.operation,
534545
file: { ...file },
535546
fileName,
536-
mimeType: downloaded.contentType,
547+
mimeType,
537548
}
538549
}
539550

540-
export const POST = withRouteHandler(async (request: NextRequest) => {
541-
const requestId = generateRequestId()
542-
let principal: WorkflowExecutionDelegatedPrincipal
543-
try {
544-
principal = await authenticateWindchillExecutor(request)
545-
} catch (error) {
546-
if (error instanceof InternalUnauthenticatedError) {
547-
return failureResponse(error.message, 401)
551+
export const POST = withRouteHandler(
552+
async (request: NextRequest) => {
553+
const requestId = generateRequestId()
554+
let principal: WorkflowExecutionDelegatedPrincipal
555+
try {
556+
principal = await authenticateWindchillExecutor(request)
557+
} catch (error) {
558+
if (error instanceof InternalUnauthenticatedError) {
559+
return failureResponse(error.message, 401)
560+
}
561+
throw error
548562
}
549-
throw error
550-
}
551563

552-
const parsed = await parseRequest(
553-
windchillOperationContract,
554-
request,
555-
{},
556-
{
557-
validationErrorResponse: (error) =>
558-
failureResponse(getValidationErrorMessage(error, 'Invalid Windchill request'), 400),
559-
}
560-
)
561-
if (!parsed.success) return parsed.response
562-
const body = parsed.data.body
564+
const parsed = await parseRequest(
565+
windchillOperationContract,
566+
request,
567+
{},
568+
{
569+
validationErrorResponse: (error) =>
570+
failureResponse(getValidationErrorMessage(error, 'Invalid Windchill request'), 400),
571+
invalidJsonResponse: () =>
572+
failureResponse('Windchill request body must be valid JSON', 400),
573+
payloadTooLargeResponse: () => failureResponse('Windchill request body is too large', 413),
574+
}
575+
)
576+
if (!parsed.success) return parsed.response
577+
const body = parsed.data.body
563578

564-
try {
565-
if (
566-
body.operation === 'windchill_download_primary_content' ||
567-
body.operation === 'windchill_download_attachment'
568-
) {
569-
return successResponse(await executeDownload(body, principal, request.signal))
570-
}
579+
try {
580+
if (
581+
body.operation === 'windchill_download_primary_content' ||
582+
body.operation === 'windchill_download_attachment'
583+
) {
584+
return successResponse(await executeDownload(body, principal, request.signal))
585+
}
571586

572-
if (
573-
body.operation === 'windchill_upload_primary_content' ||
574-
body.operation === 'windchill_upload_attachments'
575-
) {
576-
const inputs =
577-
body.operation === 'windchill_upload_primary_content'
578-
? [body.primaryFile]
579-
: body.attachmentFiles
580-
const files = await loadUploadFiles(inputs, principal.subjectUserId, requestId)
581-
if (files instanceof NextResponse) return files
582-
const uploadedFileNames = await uploadWindchillContent({
583-
params: body,
584-
documentOid: body.documentOid,
585-
files,
586-
primaryContent: body.operation === 'windchill_upload_primary_content',
587-
signal: request.signal,
588-
})
589-
return successResponse({
587+
if (
588+
body.operation === 'windchill_upload_primary_content' ||
589+
body.operation === 'windchill_upload_attachments'
590+
) {
591+
const inputs =
592+
body.operation === 'windchill_upload_primary_content'
593+
? [body.primaryFile]
594+
: body.attachmentFiles
595+
const files = await loadUploadFiles(inputs, principal.subjectUserId, requestId)
596+
if (files instanceof NextResponse) return files
597+
const uploadedFileNames = await uploadWindchillContent({
598+
params: body,
599+
documentOid: body.documentOid,
600+
files,
601+
primaryContent: body.operation === 'windchill_upload_primary_content',
602+
signal: request.signal,
603+
})
604+
return successResponse({
605+
operation: body.operation,
606+
affectedIds: [body.documentOid],
607+
uploadedFileNames,
608+
})
609+
}
610+
611+
return successResponse(await executeMutation(body, request.signal))
612+
} catch (error) {
613+
logger.error('Windchill operation failed', {
590614
operation: body.operation,
591-
affectedIds: [body.documentOid],
592-
uploadedFileNames,
615+
error: sanitizeWindchillError(getErrorMessage(error, 'Windchill operation failed')),
593616
})
617+
if (error instanceof WindchillProviderError) {
618+
const status = error.status >= 400 && error.status <= 599 ? error.status : 502
619+
return failureResponse(error.message, status)
620+
}
621+
return failureResponse(
622+
getErrorMessage(error, 'Windchill operation failed'),
623+
isPayloadSizeLimitError(error) ? 413 : 500
624+
)
594625
}
595-
596-
return successResponse(await executeMutation(body, request.signal))
597-
} catch (error) {
598-
logger.error('Windchill operation failed', {
599-
operation: body.operation,
600-
error: sanitizeWindchillError(getErrorMessage(error, 'Windchill operation failed')),
601-
})
602-
if (error instanceof WindchillProviderError) {
603-
const status = error.status >= 400 && error.status <= 599 ? error.status : 502
604-
return failureResponse(error.message, status)
605-
}
606-
return failureResponse(
607-
getErrorMessage(error, 'Windchill operation failed'),
608-
isPayloadSizeLimitError(error) ? 413 : 500
609-
)
626+
},
627+
{
628+
unhandledErrorResponse: () => failureResponse('Windchill operation failed', 500),
610629
}
611-
})
630+
)

0 commit comments

Comments
 (0)